Skip to content
Tenbri pangolinTenbri

Claude Agent SDK

Two lines record every tool call of a Claude Agent SDK agent in a Tenbri stream: tenbri.init() opens the stream, tenbri.instrument_claude_agent_sdk() (instrumentClaudeAgentSdk(options) in TypeScript) adds Tenbri’s hooks to every session. Each tool call becomes an intent that is durable before the tool runs, then its outcome. When your can_use_tool (canUseTool) callback lets a tool through, the approval is recorded too.

Python 3.10 or later.

Terminal window
pip install --pre tenbri-claude-agent-sdk

This brings the tenbri binding (0.1.0-beta.2) and claude-agent-sdk 0.1.81 or later.

Say what a refund is in tenbri.toml, next to your code. The target names the tool’s arguments in braces and is filled from each call; the policy is the rule recorded as having allowed it:

tenbri.toml
[actions.issue_refund]
target = "stripe:{charge}" # {charge} is filled from the tool call's arguments
policy = "policy:refund@14" # the rule that allowed it: ref@version

Then add the two lines to your agent. Nothing else in it changes:

agent.py
import asyncio
import tenbri
from claude_agent_sdk import ClaudeAgentOptions, create_sdk_mcp_server, query, tool
tenbri.init() # reads TENBRI_STREAM, TENBRI_AGENT... and ./tenbri.toml
tenbri.instrument_claude_agent_sdk() # every session from here on is recorded
@tool("issue_refund", "Refund a Stripe charge", {"charge": str, "amount_eur": int})
async def issue_refund(args):
return {"content": [{"type": "text", "text": f"refunded {args['amount_eur']} EUR"}]}
async def main():
options = ClaudeAgentOptions(
mcp_servers={"billing": create_sdk_mcp_server("billing", tools=[issue_refund])},
allowed_tools=["mcp__billing__issue_refund"],
)
async for message in query(prompt="Refund the duplicate charge ch_123 (30 EUR)", options=options):
print(type(message).__name__)
print("pin this:", tenbri.default().fingerprint)
asyncio.run(main())

instrument_claude_agent_sdk() patches the SDK, so every query() and ClaudeSDKClient the process starts is recorded, with your options as they are: Tenbri’s hooks go next to yours and your can_use_tool is wrapped.

The MCP tool is called mcp__billing__issue_refund; it matches the action issue_refund by the last __ part of its name.

Point Tenbri at a stream and name the agent, with ANTHROPIC_API_KEY set:

Terminal window
export TENBRI_STREAM=./audit TENBRI_AGENT=agent:support
python agent.py
SystemMessage
ResultMessage
pin this: sha256:ebe4f959a8dd65661e4798dc3149b7949d0718cee4b2e8fd2d17f3ed8fbed444

init() creates the stream and its signing keys on first use. The keys go to $TENBRI_KEYS, else ~/.config/tenbri/keys, never into the stream (keys). Keep the fingerprint: it is what you verify against. This output comes from a run against the repository’s stand-in for the Claude Code CLI (see testing offline), which plays one call of the refund tool. A real model prints more messages.

Terminal window
tenbri tail ./audit -n 2 --no-follow
{"action":{"target":"stripe:ch_123","type":"issue_refund"},"durability":"local","id":"01M5D0ZH9Y4RAB0QH0Y5ZGYHDC","inputs":[{"digest":"hmac-sha256:e5ca0aba…","ref":"claude:tool_input","version":"toolu_01"}],"kind":"decision","phase":"intent","prev":"sha256:1d736696…","principal":{"id":"support","kind":"agent"},"rule":{"ref":"policy:refund","result":"allow","version":"14"},"seq":3,"sig":"ed25519:b78cc702…","signer":"sess:2026-10-20:aa0455ad1b0b","source":"sdk","stream":"tnb1:ebe4f959a8dd65661e4798dc3149b794","ts":"2026-10-20T16:05:45.409Z","v":1}
{"action":{"target":"stripe:ch_123","type":"issue_refund"},"id":"01M5D0ZHAKNX8RA34N0B02JZXT","intent":"01M5D0ZH9Y4RAB0QH0Y5ZGYHDC","kind":"decision","phase":"outcome","prev":"sha256:3159e2eb…","principal":{"id":"support","kind":"agent"},"seq":4,"sig":"ed25519:f990eec6…","signer":"sess:2026-10-20:aa0455ad1b0b","source":"sdk","status":"ok","stream":"tnb1:ebe4f959a8dd65661e4798dc3149b794","ts":"2026-10-20T16:05:45.430Z","v":1}
Terminal window
tenbri verify ./audit --key sha256:ebe4f959… # your full fingerprint
verified 4 entries, stream tnb1:ebe4f959a8dd65661e4798dc3149b794, pinned to sha256:ebe4f959a8dd65661e4798dc3149b7949d0718cee4b2e8fd2d17f3ed8fbed444

Entries 1 and 2 are the stream’s key entries. Entry 3 is the intent, written before the refund ran: the action and target come from tenbri.toml, filled with the call’s charge, and the rule is its policy. Entry 4 is the outcome. The tool input itself is not stored, only its keyed digest.

tenbri.init() (init()) reads these; an argument (init(agent=...), init({ agent })) wins over its variable.

Variable Argument What it is
TENBRI_STREAM stream Where entries go: a directory, file://..., s3://bucket/prefix or r2://bucket/prefix. Required.
TENBRI_KEYS keys The keys directory, apart from the stream. Default ~/.config/tenbri/keys.
TENBRI_AGENT agent Who acts: agent:support. Required unless the instrumentation says otherwise.
TENBRI_APPROVER approver Who answers can_use_tool: person:alice. Without one, no approval entry is written.
TENBRI_SCHEMA schema The action schemas. Default ./tenbri.toml when there is one.
fail_open (failOpen) Let tools run, unrecorded and with a warning, when their intent cannot be written. Default: block them.

init() flushes and closes the stream when the process exits. In Python, with tenbri.init(...) as log: scopes it to a block; in TypeScript, await using log = await init(...).

A tool call is the action of the tool’s name, or of the [actions.*] table in tenbri.toml that its name ends with: mcp__billing__issue_refund is issue_refund. From that table, the record takes:

  • target, with each {argument} filled from the tool input ("stripe:{charge}" becomes stripe:ch_123; {order.id} reads a nested field). A target without placeholders is a pattern for validation only and is not recorded.
  • policy, "ref@version", as the intent’s rule: {"ref": "policy:refund", "version": "14", "result": "allow"}.

tenbri.toml is the same file tenbri schema check and tenbrid validate entries against, so the mapping and the validation cannot drift apart. A plain dict (object) in code says the same and wins over the file, per tool name. It can rename the action, and take functions of the input:

tenbri.instrument_claude_agent_sdk(
actions={
"issue_refund": {"target": "stripe:{charge}", "policy": "policy:refund@14"},
"mcp__crm__close": {"action": "close_ticket", "target": lambda args: f"ticket:{args['id']}"},
}
)

A tool neither names is recorded under its own name. File and web tools (Read, Write, Edit, WebFetch…) record their path or URL as the target; other tools, Bash included, record no target.

Python: tenbri.instrument_claude_agent_sdk(**options). TypeScript: the second argument of instrumentClaudeAgentSdk(options, { ... }).

Python TypeScript Default What it does
actions actions tenbri.toml Per tool name: action, target, subject (templates or functions of the input) and policy. Wins over the file.
agent agent tenbri.agent() / the agent() scope, else TENBRI_AGENT Who acts.
approver approver TENBRI_APPROVER Who allows a tool through can_use_tool: a principal, or a function of the call returning one.
tools tools every tool Names to record, or a predicate of the name. Other tools run unrecorded.
fail_open failOpen init()’s, False Run a tool, unrecorded and with a warning, when its intent could not be written.
timeout timeout 2.0 seconds / 2000 ms How long the intent (or approval) may take to become durable before the tool is denied.

Who acts, from the narrowest: the agent option, then the innermost with tenbri.agent("agent:...") around the session’s start (TypeScript: the agent('agent:...', fn) scope instrumentClaudeAgentSdk is called in), then TENBRI_AGENT. Nothing is guessed: with none, Python’s query() raises and TypeScript blocks the tool (see troubleshooting). In Python, tenbri_claude_agent_sdk.uninstrument() stops recording the sessions started after it.

For trace ids on entries, install OpenTelemetry: pip install --pre "tenbri-claude-agent-sdk[otel]" in Python, @opentelemetry/api and a context manager in TypeScript. Each entry then carries the active span’s trace.

The SDK calls your can_use_tool (canUseTool) when a tool needs permission. The instrumentation wraps it and records what was answered:

Your callback returns With an approver Without one
Allow An approval entry by the approver, for the same action. The tool’s outcome names it in approval: {by, entry}. Nothing extra: your code decided, not a person.
Deny The intent’s outcome is refused. The same.

A denial outside can_use_tool (a permission rule, auto mode) reaches the PermissionDenied hook and is also refused. Tools that never ask get an intent and an outcome, with no approval. If the approval cannot be written, the tool is denied (unless fail_open).

The refund agent again, now asking a person first. Keep the streamed prompt: older SDK releases call can_use_tool only with one.

refund.py
import asyncio
import tenbri
from claude_agent_sdk import (
ClaudeAgentOptions, PermissionResultAllow, PermissionResultDeny,
create_sdk_mcp_server, query, tool,
)
tenbri.init() # TENBRI_STREAM, TENBRI_AGENT, TENBRI_APPROVER, ./tenbri.toml
tenbri.instrument_claude_agent_sdk()
@tool("issue_refund", "Refund a Stripe charge", {"charge": str, "amount_eur": int})
async def issue_refund(args):
return {"content": [{"type": "text", "text": f"refunded {args['amount_eur']} EUR"}]}
async def ask(tool, tool_input, ctx):
# Your permission prompt: a person answers.
answer = await asyncio.to_thread(input, f"Allow {tool} {tool_input}? [y/N] ")
return PermissionResultAllow() if answer.lower() == "y" else PermissionResultDeny(message="no")
async def prompt(): # can_use_tool needs a streamed prompt
content = "Ticket 4512: the customer was charged twice (ch_123, 30 EUR). Refund the duplicate."
yield {"type": "user", "message": {"role": "user", "content": content}}
async def main():
options = ClaudeAgentOptions(
mcp_servers={"billing": create_sdk_mcp_server("billing", tools=[issue_refund])},
tools=[], # no built-in tools: only the refund, which asks first
can_use_tool=ask,
)
async for message in query(prompt=prompt(), options=options):
print(type(message).__name__)
for m in tenbri.default().query(action="issue_refund"):
e = m.entry
who = f"{e['principal']['kind']}:{e['principal']['id']}"
print(e["seq"], e["kind"], e.get("phase", ""), e.get("status", ""), who, e.get("approval", {}).get("entry", ""))
asyncio.run(main())
Terminal window
export TENBRI_STREAM=./audit TENBRI_AGENT=agent:support TENBRI_APPROVER=person:alice
python refund.py

Answered y:

SystemMessage
Allow mcp__billing__issue_refund {'charge': 'ch_123', 'amount_eur': 30}? [y/N] ResultMessage
3 decision intent agent:support
4 approval person:alice
5 decision outcome ok agent:support sha256:c8050e45391c7723c423315908648f93ec24ec06f695cf879828b8061659485c

Answered n:

SystemMessage
Allow mcp__billing__issue_refund {'charge': 'ch_123', 'amount_eur': 30}? [y/N] ResultMessage
3 decision intent agent:support
4 decision outcome refused agent:support

When the person who answers changes per call (the user signed in to your app, say), pass a function instead of TENBRI_APPROVER. It gets the call: (tool, tool_input, context) in Python, (tool, input, options) in TypeScript.

tenbri.instrument_claude_agent_sdk(approver=lambda tool, tool_input, ctx: f"person:{current_user()}")

current_user() (currentUser()) stands for your own lookup. With approver returning person:bob, the run above writes 4 approval person:bob, and the outcome names Bob.

The stream is wherever TENBRI_STREAM points; the code does not change.

Location TENBRI_STREAM Credentials
A local directory ./audit or file:///var/lib/audit/support none
AWS S3, MinIO s3://bucket/prefix AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION; AWS_ENDPOINT_URL (and AWS_ALLOW_HTTP=true for plain HTTP) for MinIO
Cloudflare R2 r2://bucket/prefix CLOUDFLARE_ACCOUNT_ID, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY

Signing keys stay on the machine that writes; only entries go to the bucket, with "durability":"replicated". See Storage for every variable. The bindings do not write through a tenbrid server; to send entries to tenbrid over REST, record by hand as in any other framework.

Anyone with read access to the stream and its fingerprint can check it; no keys needed. Pin the fingerprint you got at creation: without one, tenbri verify only reports consistent and NOT pinned: this only shows the stream agrees with its own genesis key.

Terminal window
tenbri verify ./audit --key sha256:ebe4f959… # the whole stream
tenbri query ./audit --action issue_refund --key sha256:ebe4f959…
tenbri query ./audit --input policy:refund@14 --key sha256:ebe4f959…
tenbri schema check ./audit # entries against tenbri.toml
tenbri tail ./audit --key sha256:ebe4f959… # follow new entries

tenbri schema check ./audit on the quick start’s stream prints:

tenbri.toml: 1 action schemas, unknown actions rejected
./audit: 4 entries, 0 break their schema

query filters by action, target, principal, subject and inputs ("ref@version"), with after_seq (afterSeq) and limit. Each match carries verification: verified when a key is pinned, unpinned when not. See the CLI reference.

The adapter’s own tests run the real SDK against testing/fake-claude.mjs, a stand-in for the Claude Code CLI: no model, no API key, no cost. Copy it into your tests and point the SDK at it. It needs Node on the PATH.

Terminal window
curl -fsSLO https://raw.githubusercontent.com/tenbri/tenbri/main/adapters/claude-agent-sdk/testing/fake-claude.mjs
chmod +x fake-claude.mjs

FAKE_CLAUDE_SCENARIO is a JSON array of the tool calls to play, after the first user message:

Field Meaning
tool, input, id The tool name, its input, and the tool use id
ask true to call your can_use_tool before running it
result "ok", "error", "interrupt", "denied" (a permission rule denies it) or "crash" (the CLI dies after PreToolUse)

Give init() a stream and keys in a temporary directory, so tests never touch your real keys:

test_agent.py
import asyncio
import json
from pathlib import Path
import tenbri
from claude_agent_sdk import ClaudeAgentOptions, query
FAKE = Path(__file__).parent / "fake-claude.mjs" # copied from the Tenbri repository
REFUNDS = {"issue_refund": {"target": "stripe:{charge}", "policy": "policy:refund@14"}}
async def run_agent(calls):
"""Runs the real SDK against the fake CLI, which plays `calls` as the model's tool calls."""
options = ClaudeAgentOptions(cli_path=str(FAKE), env={"FAKE_CLAUDE_SCENARIO": json.dumps(calls)})
async for _ in query(prompt="Refund ch_123", options=options):
pass
def test_each_tool_call_is_recorded(tmp_path):
# A stream and keys of its own: the test never touches your real keys directory.
with tenbri.init(tmp_path / "audit", keys=tmp_path / "keys", agent="agent:support") as log:
tenbri.instrument_claude_agent_sdk(actions=REFUNDS)
asyncio.run(run_agent([
{"tool": "mcp__billing__issue_refund", "input": {"charge": "ch_123"}, "id": "t1", "result": "ok"},
{"tool": "Bash", "input": {"command": "make"}, "id": "t2", "result": "error"},
]))
entries = [m.entry for m in log.query(principal="agent:support")]
assert [(e["action"]["type"], e["phase"], e.get("status")) for e in entries] == [
("issue_refund", "intent", None),
("issue_refund", "outcome", "ok"),
("Bash", "intent", None),
("Bash", "outcome", "error"),
]
assert entries[0]["action"]["target"] == "stripe:ch_123"
assert entries[0]["rule"]["ref"] == "policy:refund"
assert "target" not in entries[2]["action"] # the command is never stored
assert log.verify(log.fingerprint).ok
Terminal window
python -m pytest -q test_agent.py
. [100%]
1 passed in 0.67s

When the instrumentation blocks a tool, the agent receives the reason as the hook’s denial, so it shows up in the model’s transcript. These messages come from real runs; paths are shortened.

Message Cause What to do
InvalidRequestError: no stream: pass tenbri.init(stream) or set TENBRI_STREAM to a directory or s3://bucket/prefix (TypeScript: pass init({ stream })) init() found no stream. Set TENBRI_STREAM, or pass the stream to init().
InvalidRequestError: Tenbri is not initialized: call tenbri.init() first, raised by query() (TypeScript: the tool is blocked with call init() first) A session started before init(). Call init() at startup, before the first session.
InvalidRequestError: no principal: pass agent= to tenbri.init(), set TENBRI_AGENT, or use with tenbri.agent("agent:..."), raised by query() Nothing says who the agent is. Set TENBRI_AGENT, or pass agent.
TypeScript: Tenbri could not record the intent, so mcp__billing__issue_refund is blocked: no principal: pass agent to init(), set TENBRI_AGENT, or run in agent('agent:...', fn) The same, found when the tool is called. The same.
Tenbri could not record the intent, so mcp__billing__issue_refund is blocked: 'stripe:{charge_id}': no argument 'charge_id' (TypeScript: "stripe:{charge_id}": no argument charge_id) The target names an argument the tool’s input does not have. Match the placeholder to the tool’s input schema.
InvalidRequestError: tenbri.toml: actions.issue_refund: policy: expected ref@version A policy without its version, raised by init(). Write policy = "policy:refund@14".
Tenbri could not record the intent, so mcp__billing__issue_refund is blocked: read-only: no stream key for tnb1:… in …/keys The stream exists, but its keys are not in this keys directory. Set TENBRI_KEYS to the directory the stream was created with.
Tenbri could not record the intent, so Read is blocked: not durable within 2000 ms (TypeScript; Python has nothing after the colon) The write took longer than timeout, often a slow bucket. Raise timeout, or move the stream closer to the agent.
Tenbri could not record the approval: … The approval entry could not be written, so the allowed tool is denied and recorded refused. Same causes as above.
tenbri: Read runs unrecorded (fail-open): … (a warning) fail_open is on and the intent could not be written; the tool ran. Fix the stream; the call is not in the record.
ImportError: …: pip install --pre tenbri-claude-agent-sdk tenbri.instrument_claude_agent_sdk() without the adapter installed. Install it.
can_use_tool allowed the tool, but there is no approval entry No approver: no TENBRI_APPROVER, init(approver=...) or approver option. Set one, if a person answers.
can_use_tool is never called The tool was allowed by settings, or (on older SDKs) the prompt was a string. Pass a streamed prompt, as in Approvals.

If the process dies after PreToolUse, no outcome is written. The next writer of the stream records the open intent as unknown. An outcome that cannot be written is logged (the ok outcome of … was not recorded), not retried: the tool already ran.

SDK event Entry
PreToolUse An intent: principal the agent, action.type the action, action.target and rule from the mapping, the tool input pinned by its keyed digest. Durable before the tool runs.
can_use_tool allows, with an approver An approval by that person, for the same action
PostToolUse The outcome ok, naming the approval if there was one
PostToolUseFailure The outcome error, or unknown when the tool was interrupted
can_use_tool denies, or PermissionDenied The outcome refused

Nothing of the prompt, the tool input or the tool output is stored. The intent pins the tool input by its keyed digest, as an input with ref claude:tool_input and the tool use id as its version. The digest is keyed by the stream’s local key store, per subject, so no secret goes in your code; whoever holds the input and the key can prove what the agent sent.

The three entries of the approved refund above, as stored (signatures and hashes shortened):

{
"action": { "target": "stripe:ch_123", "type": "issue_refund" },
"durability": "local",
"id": "01M5D10NHDVEATSX2KYR2DNAGG",
"inputs": [
{ "digest": "hmac-sha256:0f1fcbeb…", "ref": "claude:tool_input", "version": "toolu_01" }
],
"kind": "decision",
"phase": "intent",
"prev": "sha256:3615d3d1…",
"principal": { "id": "support", "kind": "agent" },
"rule": { "ref": "policy:refund", "result": "allow", "version": "14" },
"seq": 3,
"sig": "ed25519:4c0b4620…",
"signer": "sess:2026-10-20:fbe917a1a6e1",
"source": "sdk",
"stream": "tnb1:4a73f3cad8903b87b0c084459e059903",
"ts": "2026-10-20T16:06:22.512Z",
"v": 1
}
{
"action": { "target": "stripe:ch_123", "type": "issue_refund" },
"id": "01M5D10NJ189CSXFKSYQPM12H2",
"kind": "approval",
"prev": "sha256:2ec90a27…",
"principal": { "id": "alice", "kind": "person" },
"seq": 4,
"sig": "ed25519:7ddec541…",
"signer": "sess:2026-10-20:fbe917a1a6e1",
"stream": "tnb1:4a73f3cad8903b87b0c084459e059903",
"ts": "2026-10-20T16:06:22.532Z",
"v": 1
}
{
"action": { "target": "stripe:ch_123", "type": "issue_refund" },
"approval": {
"by": { "id": "alice", "kind": "person" },
"entry": "sha256:e6cfa970…"
},
"id": "01M5D10NJP6PC66AW4RSPG69KD",
"intent": "01M5D10NHDVEATSX2KYR2DNAGG",
"kind": "decision",
"phase": "outcome",
"prev": "sha256:e6cfa970…",
"principal": { "id": "support", "kind": "agent" },
"seq": 5,
"sig": "ed25519:c2d2033d…",
"signer": "sess:2026-10-20:fbe917a1a6e1",
"source": "sdk",
"status": "ok",
"stream": "tnb1:4a73f3cad8903b87b0c084459e059903",
"ts": "2026-10-20T16:06:22.553Z",
"v": 1
}

The outcome’s approval.entry is the hash of entry 4, which is also entry 5’s prev. See the entry format for every field.

The instrumentation is built on TenbriHooks: one session’s hooks on a stream you opened yourself, with no default stream and no environment. Use it to hold several streams, or to record some sessions and not others. It writes the same entries.

hooks.py
import asyncio
from claude_agent_sdk import ClaudeAgentOptions, query
from tenbri import AsyncTenbri
from tenbri_claude_agent_sdk import TenbriHooks, default_describe
def describe(tool, tool_input):
if tool == "mcp__billing__issue_refund":
return {"action": "issue_refund", "target": f"stripe:{tool_input['charge']}"}
return default_describe(tool, tool_input) # file and web tools: their path or URL
async def main():
log = await AsyncTenbri.open("./audit", create=True)
hooks = TenbriHooks(log, "agent:support", describe=describe, approver="person:alice")
options = hooks.instrument(ClaudeAgentOptions(allowed_tools=["mcp__billing__issue_refund"]))
async for message in query(prompt="Refund ch_123", options=options):
pass
print([(m.entry["phase"], m.entry["action"]) for m in await log.query(principal="agent:support")])
await log.close()
asyncio.run(main())

Both print the intent and the ok outcome of issue_refund on stripe:ch_123. TenbriHooks(log, principal, ...) takes tools, describe ((tool, input) to any of action, target, inputs, rule, subject), approver, fail_open (failOpen) and timeout, as the options above. hooks.can_use_tool(fn) (hooks.canUseTool(fn)) wraps a permission callback on its own, and entry_hash(entry) (entryHash) is the hash an approval member names.