Python and TypeScript SDK

The tenbri package for Python and Node.js: init(), decision, agent(), Refused, the framework instrumentation, and the stream API they are built on.

The tenbri package for Python and Node.js writes through the same Rust core as the tenbri CLI and tenbrid. This page covers version 0.1.0-beta.3.

pip install --pre tenbri

Python 3.10 and later. Tenbri blocks (the GIL is released); AsyncTenbri never blocks the event loop.

It has two levels:

  • The default stream: init() opens one stream for the process, decision records each call of a function, agent() says who acts, and one line instruments an agent framework. Most applications need only this.
  • The stream API: Tenbri.open and the methods on a stream (run, decision, read, intent, outcome, verify, query). The default stream is one of these, so all of them work on it too.

init

Opens the process's default stream, creating the stream and its keys if there is none, and closes it (flushing) when the process exits.

def init(
    stream: str | os.PathLike[str] | None = None,
    *,
    keys: str | os.PathLike[str] | None = None,
    agent: Principal | None = None,
    approver: Principal | None = None,
    schema: str | os.PathLike[str] | None = None,
    key: str | None = None,
    digest_key: bytes | None = None,
    fail_open: bool = False,
) -> Default

An argument given wins over its environment variable:

PythonTypeScriptVariableDefaultMeaning
streamstreamTENBRI_STREAMrequiredThe stream: a directory, file://..., s3://bucket/prefix or r2://bucket/prefix (Storage)
keyskeysTENBRI_KEYS~/.config/tenbri/keysThe keys directory, never inside the stream
agentagentTENBRI_AGENTnoneWho acts when nothing narrower says (principal resolution)
approverapproverTENBRI_APPROVERnoneWho answers the frameworks' approval prompts (person:alice)
schemaschemaTENBRI_SCHEMA./tenbri.toml when there is oneThe action schemas, read to map tool calls to actions
keykeynoneThe genesis key to pin: sha256:... or ed25519:...
digest_keydigestKeythe local key storeKeys input digests yourself instead of with the stream's data keys (Privacy)
fail_openfailOpenfalseLet a decorated function run, unrecorded and with a warning, when its intent cannot be written. By default it does not run.
  • The stream is opened with create: the first init() on an empty location makes the stream and its keys, like tenbri init. Later calls open it.
  • The schema file is checked as tenbri schema check checks it; an invalid one fails init() with the same message. The SDKs do not check entries against it.
  • Calling init() again closes the previous default and opens the new one.
  • Python: init() returns the open stream, and closes it at interpreter exit (atexit). with tenbri.init(...) as log: closes it at the end of the block instead.
  • TypeScript: init() returns a Promise; awaiting it is optional, because a decision waits for it (and throws what it threw). It is closed on the process's beforeExit. await using log = await init(...) closes it at the end of the scope.
  • Without a stream: InvalidRequestError, no stream: pass tenbri.init(stream) or set TENBRI_STREAM to a directory or s3://bucket/prefix (TypeScript: pass init({ stream })).

The value returned is a Default: a Tenbri stream with the settings the rest of the process uses, agent, approver, actions (the action schemas of tenbri.toml, by type) and fail_open (failOpen). In Python, .aio is the same stream as an AsyncTenbri.

tenbri.default() (Python) and defaultStream() (TypeScript, a Promise) return it; before init(), they fail with InvalidRequestError: Tenbri is not initialized: call tenbri.init() first (call init() first).

import tenbri

tenbri.init()  # TENBRI_STREAM, TENBRI_AGENT...

with tenbri.init("audit/support", agent="agent:support") as log:  # scoped to the block
    print(log.fingerprint)

decision

Records each call of a function as a decision on the default stream: the intent is durable before the function runs, then the outcome follows.

def decision(
    action: str,
    target: str | Callable[..., str] | None = None,
    *,
    policy: str | None = None,
    inputs: Iterable[Pin] | Callable[..., Iterable[Pin]] | None = None,
    subject: str | Callable[..., str] | None = None,
    principal: Principal | None = None,
    fail_open: bool | None = None,
    **fields: Any,
) -> Callable[[F], F]

A decorator, of sync and async functions alike (an async function writes through .aio, without blocking the event loop).

ParameterDefaultMeaning
actionrequiredThe action type (action.type)
targetnoneA template of the arguments, or a function of them: action.target
subjectnoneA template or function: the entry's subject, and the key its input digests use
policynoneref@version (policy:refund@14): recorded as rule: {ref, version, result: "allow"}. Split at the last @; anything else fails with policy <value>: expected ref@version.
inputsnoneWhat the decision relied on, pinned with a keyed digest. Python: (ref, version, content) tuples, or {"ref", "version", "content"} or {"ref", "version", "digest"}; TypeScript: { ref, version, content } or { ref, version, digest }. Or a function of the arguments returning them.
principalsee Who actsWho acts: agent:support, {kind, id}, or a bare id (an agent)
fail_open (failOpen)init()'sRun the function, unrecorded and with a warning, when the intent cannot be written
other fieldsAny other member of a draft entry (ext, links, trace...), copied into the intent

Outcomes

The functionOutcome statusThen
ReturnsokIts value is returned
Raises (throws) RefusedrefusedRe-raised
Raises any other exceptionerrorRe-raised
Is cancelled: Python CancelledError, KeyboardInterrupt; TypeScript an AbortErrorunknownRe-raised

If the intent cannot be written, the function does not run and the TenbriError propagates (fail closed), unless fail_open is set. If the outcome cannot be written, the function's result stands: a warning is logged, and the open intent is reconciled as unknown by the stream's next writer.

Templates

target and subject strings are templates: each {name} is replaced with an argument, and {name.key} reads into it (a key of a mapping, or an attribute in Python). A missing argument fails the call, before the function runs, with InvalidRequestError: 'x:{missing}': no argument 'missing' (TypeScript: "x:{missing}": no argument missing).

  • Python binds the template to the function's parameters by name, defaults included: "stripe:{charge}" is the charge argument, however it was passed.
  • TypeScript has no parameter names: {charge} reads the first argument's charge member, and {0}, {1} are positional arguments ({0.id} reads the first argument's id).

Who acts

The principal is, in order: principal=; else the innermost agent() scope; else init(agent=...) or TENBRI_AGENT. With none, the call fails with InvalidRequestError: no principal: pass agent= to tenbri.init(), set TENBRI_AGENT, or use with tenbri.agent("agent:...") (TypeScript: no principal: pass agent to init(), set TENBRI_AGENT, or run in agent('agent:...', fn)). Nothing is guessed.

Inputs and digests

Each input is pinned with the keyed digest of its content: under digest_key if init() was given one, else under the decision's subject's data key (the tenant's when there is no subject) from the stream's local key store, made on first use. Erasing the subject with tenbri erase leaves its digests unconfirmable (Privacy).

In Python, an intent also gets trace from the active OpenTelemetry span when OpenTelemetry is installed and tracing.

Example

import asyncio
import tenbri

tenbri.init("audit/support", agent="agent:support")

@tenbri.decision(
    "issue_refund",
    target="stripe:{charge}",
    policy="policy:refund@14",
    subject="{customer}",
    inputs=[("policy:refund", "14", b"refunds under 500 are automatic")],
)
def refund(charge: str, amount: int, customer: str) -> str:
    if amount > 500:
        raise tenbri.Refused("over the ceiling")
    return "re_1"

@tenbri.decision("reply_ticket", target="zendesk:ticket/{ticket.id}")
async def reply(ticket: dict) -> None: ...

print(refund("ch_123", 20, "subj_7f3c91d2"))
try:
    refund("ch_124", 900, "subj_7f3c91d2")
except tenbri.Refused as e:
    print("Refused:", e)
with tenbri.agent("agent:billing"):
    asyncio.run(reply({"id": 42}))

for m in tenbri.default().query(action="issue_refund"):
    e = m.entry
    print(e["seq"], e["phase"], e.get("status"), e["action"]["target"], e.get("rule"))
re_1
Refused: over the ceiling
3 intent None stripe:ch_123 {'ref': 'policy:refund', 'result': 'allow', 'version': '14'}
4 outcome ok stripe:ch_123 None
5 intent None stripe:ch_124 {'ref': 'policy:refund', 'result': 'allow', 'version': '14'}
6 outcome refused stripe:ch_124 None

The intents carry subject: subj_7f3c91d2 and inputs: [{"ref": "policy:refund", "version": "14", "digest": "hmac-sha256:e134149c…"}]; the reply_ticket entries (seq 7 and 8) are by agent:billing, target zendesk:ticket/42.

agent

Sets the principal for a scope: decisions in it, and in the tasks, threads and async work it starts, are by that principal unless they name their own.

@contextlib.contextmanager
def agent(principal: Principal) -> Iterator[None]
with tenbri.agent("agent:billing"):
    refund("ch_123", 20, "subj_7f3c91d2")

A ContextVar: asyncio tasks and contextvars.copy_context() carry it.

A Principal is "agent:support", "person:alice", "service:billing", a bare id ("support", an agent), or {kind, id} with kind one of agent, person, service.

Refused

Raise (throw) it inside a decision when your own policy refuses the action: the outcome is recorded as refused, and it propagates.

class Refused(Exception): ...

raise tenbri.Refused("over the ceiling")

It works the same in the stream API's decisions (log.decision, run.decision).

Framework instrumentation

One call records every tool call of an agent framework on the default stream: an intent before the tool runs, its outcome after, and approvals through the framework's own approval flow. Call it once, after init().

FrameworkPython (tenbri.)TypeScriptPackage to install
Claude Agent SDKinstrument_claude_agent_sdk(**options)instrumentClaudeAgentSdk(options, o) from @tenbri/claude-agent-sdktenbri-claude-agent-sdk, @tenbri/claude-agent-sdk
OpenAI Agents SDKinstrument_openai_agents(**options)instrumentOpenAIAgents(o) from @tenbri/openai-agentstenbri-openai-agents, @tenbri/openai-agents
LangChain and LangGraphinstrument_langchain(**options)instrumentLangChain(o) from @tenbri/langgraphtenbri-langgraph, @tenbri/langgraph

In Python the functions are in tenbri and import the adapter package; without it they raise ImportError naming the pip install --pre command. In TypeScript they are exported by the adapter packages. instrumentClaudeAgentSdk wraps the Options of one query() and returns them; the others patch the framework for every run from then on.

Options, all optional:

PythonTypeScriptMeaning
actionsactionsPer tool name: {"action", "target", "subject", "policy"}, over the action schemas of tenbri.toml (In the SDKs)
agentagentWho acts. Claude and LangChain only; the OpenAI Agents SDK records agent:<the agent's name>. Default: the agent() scope, else (LangChain) the agent's name, else init()'s.
approverapproverWho answers an approval prompt: a principal, or a function of the framework's call. Default init(approver=...), else TENBRI_APPROVER.
toolstoolsWhich tools to record: names, or a predicate
fail_openfailOpenDefault init()'s
timeouttimeoutClaude only: how long a hook waits for its write before failing: seconds in Python (default 2.0), milliseconds in TypeScript (default 2000)
subjectsubjectOpenAI Agents SDK only: the subject of every entry
import tenbri

tenbri.init()
tenbri.instrument_claude_agent_sdk()  # or instrument_openai_agents(), instrument_langchain()

What each framework records, and its lower-level hooks, are in Adapters.

describe

How a tool call becomes intent fields, exposed for your own integrations:

def describe(tool: str, args: Mapping[str, Any], actions: Actions | None = None) -> dict[str, Any]

It returns action (when it differs from the tool's name), target, subject and rule, from actions over the default stream's action schemas. The rules and an example are in Action schemas.

Tenbri.open

The stream API: open a stream yourself, to hold several, or to see every call.

class Tenbri:
    @classmethod
    def open(
        cls,
        url: str | os.PathLike[str],
        *,
        keys: str | os.PathLike[str] | None = None,
        key: str | None = None,
        create: bool = False,
        digest_key: bytes | None = None,
    ) -> Tenbri

class AsyncTenbri:
    @classmethod
    async def open(...) -> AsyncTenbri  # the same arguments

AsyncTenbri has the same methods as coroutines; async with closes it.

ArgumentDefaultMeaning
urlrequiredA directory, file://..., s3://bucket/prefix or r2://bucket/prefix (credentials from AWS_*; CLOUDFLARE_ACCOUNT_ID for R2)
keys$TENBRI_KEYS, else ~/.config/tenbri/keysThe keys directory. Without the stream's keys there, the stream opens read-only: verify and query work, writes fail with NoSessionKeyError.
keynonePins the genesis key (sha256:... or ed25519:...) for writes, verify and query
createfalseCreate the stream and its keys if there is none
digest_key (digestKey)none: the local key storeKeys input digests with HMAC-SHA-256 directly

A stream has id (tnb1:...), fingerprint (what a verifier pins) and writable.

Methods

PythonTypeScriptReturnsMeaning
run(principal)run({ principal }, fn)a Run (Python, a context manager); fn's resultDecisions by one principal; what it reads is pinned to its next decision
run.read(ref, version, content=None, *, digest=None)run.read(ref, { version, content?, digest? })Records a read: the next decision lists it among its inputs, with the keyed digest of content (or digest as given)
decision(action, target=None, *, principal=None, **fields)decision({ action, target, ...fields }, fn)Python: a context manager and decorator; TypeScript: fn's resultIntent before, outcome after: ok, refused on Refused, error otherwise. As a Python decorator, target may be a function of the arguments. Also on run.
intent(draft=None, /, **fields)intent(draft)the entry as storedRecords an intent, durable before the action runs
outcome(intent, status="ok", /, **fields)outcome(intent, status = 'ok', fields)the entry as storedRecords the outcome of intent (the entry or its id); principal, action, subject and source default to the intent's
append(entry)append(entry)the entry as storedAppends any draft (note, approval, decision). Give it an id (new_id(), newId()) to make retries idempotent.
digest(content, *, subject=None)digest(content, { subject })hmac-sha256:<hex>The keyed digest of an input: with digest_key if given, else subject's data key (the tenant's without one) from the local key store, made on first use
verify(key=None)verify(key?)a reportChecks the whole stream against key, else the key pinned at open. ok only when nothing is broken and a key was pinned.
query(*, action, target, principal, subject, inputs, after_seq=0, limit)query({ action, target, principal, subject, inputs, afterSeq, limit })matchesEntries matching every filter, in order, each with verification (verified, unpinned with no key pinned, or broken) and entry. inputs are "ref@version" strings or (ref, version) pairs.
flush()flush()Waits for every append queued so far
close()close()Flushes and stops the writer; reads still work. Python: with closes; TypeScript: await using.

run.read digests under the tenant's data key (it has no subject); to digest under a subject's key, pass digest=log.digest(content, subject=...), or use inputs on the high-level decision.

from tenbri import Tenbri

log = Tenbri.open("audit/support", create=True)
print("pin this:", log.fingerprint)

with log.run("agent:support-assistant") as run:
    run.read("policy:refund", "14", b"refunds under 50 EUR are automatic")
    with run.decision("issue_refund", "stripe:ch_123") as d:
        pass  # issue the refund here; raise Refused(...) when your policy says no
print(d.intent["inputs"])

report = log.verify(log.fingerprint)
print(report.ok, report.entries)
for m in log.query(inputs=["policy:refund@14"]):
    print(m.verification, m.entry["action"])
log.close()

Python prints (the digest differs per stream, its data key being random):

pin this: sha256:026cfe12eeed02fe2fcde877832504ebe6289de02ac812f0fdbf07f0a4a1397b
[{'digest': 'hmac-sha256:9e7aa6048ae664013cd18b24200969866b3db8fba53fb1f4a71a8f8a05c043a7', 'ref': 'policy:refund', 'version': '14'}]
True 4
unpinned {'target': 'stripe:ch_123', 'type': 'issue_refund'}

query says unpinned because the stream was opened without key; verify was given the fingerprint, so report.ok is true.

Errors

Every failure is a TenbriError with a stable code, shared with tenbrid (API), reason (the format v1 code, where there is one, Errors) and id (the entry being written: retry with it).

Classcode
InvalidRequestErrorTENBRI_INVALID_REQUEST: also no stream, no principal, a missing template argument, a malformed policy
InvalidEntryErrorTENBRI_INVALID_ENTRY
NotFoundErrorTENBRI_NOT_FOUND
IdempotencyConflictErrorTENBRI_IDEMPOTENCY_CONFLICT
WriteConflictErrorTENBRI_WRITE_CONFLICT
DeadlineExceededErrorTENBRI_DEADLINE_EXCEEDED
StoreUnavailableErrorTENBRI_STORE_UNAVAILABLE
NoSessionKeyErrorTENBRI_NO_SESSION_KEY: the stream is read-only on this machine
StreamBrokenErrorTENBRI_STREAM_BROKEN

With fail_open, a decorated function whose intent fails with any of these runs anyway, and a warning is logged:

tenbri: noop runs unrecorded (fail-open): read-only: no stream key for tnb1:7975387dece6df1938db869d9f2844d2 in otherkeys

The SDKs do not check action schemas, rotate signing keys or seal encrypted bodies. Schemas are checked by tenbrid and tenbri schema check; keys and bodies are in the Rust crates (Keys, Privacy).

Edit on GitHub

On this page