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.
Quick start
Section titled “Quick start”1. Install
Section titled “1. Install”Python 3.10 or later.
pip install --pre tenbri-claude-agent-sdkuv add --prerelease=allow tenbri-claude-agent-sdkpoetry add --allow-prereleases tenbri-claude-agent-sdkThis brings the tenbri binding (0.1.0-beta.2) and claude-agent-sdk 0.1.81 or later.
Node 20 or later.
npm install @tenbri/claude-agent-sdk@next tenbri@next @anthropic-ai/claude-agent-sdk zodpnpm add @tenbri/claude-agent-sdk@next tenbri@next @anthropic-ai/claude-agent-sdk zodyarn add @tenbri/claude-agent-sdk@next tenbri@next @anthropic-ai/claude-agent-sdk zodbun add @tenbri/claude-agent-sdk@next tenbri@next @anthropic-ai/claude-agent-sdk zodThe adapter needs @anthropic-ai/claude-agent-sdk 0.2.141 or later; zod describes the tool’s
input. The snippets on this page are ES modules with top-level await: set "type": "module" in
package.json (npm pkg set type=module).
2. Instrument your agent
Section titled “2. Instrument your agent”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:
[actions.issue_refund]target = "stripe:{charge}" # {charge} is filled from the tool call's argumentspolicy = "policy:refund@14" # the rule that allowed it: ref@versionThen add the two lines to your agent. Nothing else in it changes:
import asyncio
import tenbrifrom claude_agent_sdk import ClaudeAgentOptions, create_sdk_mcp_server, query, tool
tenbri.init() # reads TENBRI_STREAM, TENBRI_AGENT... and ./tenbri.tomltenbri.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.
import { createSdkMcpServer, query, tool } from '@anthropic-ai/claude-agent-sdk'import { defaultStream, init } from 'tenbri'import { instrumentClaudeAgentSdk } from '@tenbri/claude-agent-sdk'import { z } from 'zod'
init() // reads TENBRI_STREAM, TENBRI_AGENT... and ./tenbri.toml
const billing = createSdkMcpServer({ name: 'billing', tools: [ tool('issue_refund', 'Refund a Stripe charge', { charge: z.string(), amount_eur: z.number().int() }, async (a) => ({ content: [{ type: 'text', text: `refunded ${a.amount_eur} EUR` }], })), ],})
// Your options, returned with Tenbri's hooks addedconst options = instrumentClaudeAgentSdk({ mcpServers: { billing }, allowedTools: ['mcp__billing__issue_refund'],})for await (const message of query({ prompt: 'Refund the duplicate charge ch_123 (30 EUR)', options })) { console.log(message.type)}console.log('pin this:', (await defaultStream()).fingerprint)instrumentClaudeAgentSdk(options) returns your options with Tenbri’s hooks next to yours and your
canUseTool wrapped: wrap the options of each session you want recorded. (An ES module’s exports
cannot be patched, so unlike Python it does not reach sessions you do not pass through it.)
Awaiting init() is optional: the first tool call waits for it.
The MCP tool is called mcp__billing__issue_refund; it matches the action issue_refund by the
last __ part of its name.
3. Run it
Section titled “3. Run it”Point Tenbri at a stream and name the agent, with ANTHROPIC_API_KEY set:
export TENBRI_STREAM=./audit TENBRI_AGENT=agent:supportpython agent.pySystemMessageResultMessagepin this: sha256:ebe4f959a8dd65661e4798dc3149b7949d0718cee4b2e8fd2d17f3ed8fbed444Node 22.18 or later runs .ts files as they are; on Node 20, use npx tsx agent.ts.
export TENBRI_STREAM=./audit TENBRI_AGENT=agent:supportnode agent.tssystemresultpin this: sha256:c65b8677e5b17f458ac3939ba74a4f9b18dcb6aed257bb7022d22b3dcb4a702einit() 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.
4. See the entries
Section titled “4. See the entries”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}tenbri verify ./audit --key sha256:ebe4f959… # your full fingerprintverified 4 entries, stream tnb1:ebe4f959a8dd65661e4798dc3149b794, pinned to sha256:ebe4f959a8dd65661e4798dc3149b7949d0718cee4b2e8fd2d17f3ed8fbed444import sys
from tenbri import Tenbri
# key: the fingerprint you pinned when the stream was createdlog = Tenbri.open("./audit", key=sys.argv[1])report = log.verify()print("ok:", report.ok, "entries:", report.entries)for m in log.query(action="issue_refund"): e = m.entry print(m.verification, e["seq"], e["phase"], e.get("status", ""), e["action"], e.get("rule", ""))log.close()python read.py sha256:ebe4f959… # your full fingerprintok: True entries: 4verified 3 intent {'target': 'stripe:ch_123', 'type': 'issue_refund'} {'ref': 'policy:refund', 'result': 'allow', 'version': '14'}verified 4 outcome ok {'target': 'stripe:ch_123', 'type': 'issue_refund'}import { Tenbri } from 'tenbri'
// key: the fingerprint you pinned when the stream was createdconst log = await Tenbri.open('./audit', { key: process.argv[2] })const report = await log.verify()console.log('ok:', report.ok, 'entries:', report.entries)for (const { verification, entry: e } of await log.query({ action: 'issue_refund' })) { console.log(verification, e.seq, e.phase, e.status ?? '', e.action, e.rule ?? '')}await log.close()node read.ts sha256:c65b8677… # your full fingerprintok: true entries: 4verified 3 intent { target: 'stripe:ch_123', type: 'issue_refund' } { ref: 'policy:refund', result: 'allow', version: '14' }verified 4 outcome ok { target: 'stripe:ch_123', type: 'issue_refund' }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.
Configuration
Section titled “Configuration”Environment variables
Section titled “Environment variables”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(...).
Map tool calls to actions
Section titled “Map tool calls to actions”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}"becomesstripe: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’srule:{"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']}"}, })const options = instrumentClaudeAgentSdk(yourOptions, { actions: { issue_refund: { target: 'stripe:{charge}', policy: 'policy:refund@14' }, mcp__crm__close: { action: 'close_ticket', target: (args) => `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.
Options
Section titled “Options”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.
Approvals
Section titled “Approvals”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.
import asyncio
import tenbrifrom claude_agent_sdk import ( ClaudeAgentOptions, PermissionResultAllow, PermissionResultDeny, create_sdk_mcp_server, query, tool,)
tenbri.init() # TENBRI_STREAM, TENBRI_AGENT, TENBRI_APPROVER, ./tenbri.tomltenbri.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())export TENBRI_STREAM=./audit TENBRI_AGENT=agent:support TENBRI_APPROVER=person:alicepython refund.pyAnswered y:
SystemMessageAllow mcp__billing__issue_refund {'charge': 'ch_123', 'amount_eur': 30}? [y/N] ResultMessage3 decision intent agent:support4 approval person:alice5 decision outcome ok agent:support sha256:c8050e45391c7723c423315908648f93ec24ec06f695cf879828b8061659485cAnswered n:
SystemMessageAllow mcp__billing__issue_refund {'charge': 'ch_123', 'amount_eur': 30}? [y/N] ResultMessage3 decision intent agent:support4 decision outcome refused agent:supportimport { createInterface } from 'node:readline/promises'import { createSdkMcpServer, query, tool, type CanUseTool, type SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'import { init } from 'tenbri'import { instrumentClaudeAgentSdk } from '@tenbri/claude-agent-sdk'import { z } from 'zod'
const log = await init() // TENBRI_STREAM, TENBRI_AGENT, TENBRI_APPROVER, ./tenbri.toml
const billing = createSdkMcpServer({ name: 'billing', tools: [ tool('issue_refund', 'Refund a Stripe charge', { charge: z.string(), amount_eur: z.number().int() }, async (a) => ({ content: [{ type: 'text', text: `refunded ${a.amount_eur} EUR` }], })), ],})
// Your permission prompt: a person answers.const ask: CanUseTool = async (name, input) => { const rl = createInterface({ input: process.stdin, output: process.stdout }) const answer = await rl.question(`Allow ${name} ${JSON.stringify(input)}? [y/N] `) rl.close() return answer.toLowerCase() === 'y' ? { behavior: 'allow', updatedInput: input } : { behavior: 'deny', message: 'no' }}
// canUseTool needs a streamed promptasync function* prompt(): AsyncGenerator<SDKUserMessage> { const content = 'Ticket 4512: the customer was charged twice (ch_123, 30 EUR). Refund the duplicate.' yield { type: 'user', message: { role: 'user', content }, parent_tool_use_id: null, session_id: '' }}
const options = instrumentClaudeAgentSdk({ mcpServers: { billing }, tools: [], // no built-in tools: only the refund, which asks first canUseTool: ask,})for await (const message of query({ prompt: prompt(), options })) console.log(message.type)
for (const { entry: e } of await log.query({ action: 'issue_refund' })) { console.log(e.seq, e.kind, e.phase ?? '', e.status ?? '', `${e.principal?.kind}:${e.principal?.id}`, e.approval ?? '')}export TENBRI_STREAM=./audit TENBRI_AGENT=agent:support TENBRI_APPROVER=person:alicenode refund.tsAnswered y:
systemAllow mcp__billing__issue_refund {"charge":"ch_123","amount_eur":30}? [y/N] result3 decision intent agent:support4 approval person:alice5 decision outcome ok agent:support { by: { id: 'alice', kind: 'person' }, entry: 'sha256:c220dadd914de0de0ff9ee80a89f6c76d85d6d352802130bfe5dbdc118183f5e'}Answered n:
systemAllow mcp__billing__issue_refund {"charge":"ch_123","amount_eur":30}? [y/N] result3 decision intent agent:support4 decision outcome refused agent:supportWhen 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()}")const options = instrumentClaudeAgentSdk(yourOptions, { approver: (tool, input, opts) => `person:${currentUser()}` })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.
Choose where entries go
Section titled “Choose where entries go”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.
Read back and verify
Section titled “Read back and verify”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.
tenbri verify ./audit --key sha256:ebe4f959… # the whole streamtenbri 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.tomltenbri tail ./audit --key sha256:ebe4f959… # follow new entriestenbri schema check ./audit on the quick start’s stream prints:
tenbri.toml: 1 action schemas, unknown actions rejected./audit: 4 entries, 0 break their schemalog = Tenbri.open("./audit", key="sha256:ebe4f959…")report = log.verify() # report.ok, report.entries, report.brokenrefunds = log.query(action="issue_refund", target="stripe:ch_123")by_input = log.query(inputs=["claude:tool_input@toolu_01"])From inside the agent’s process, tenbri.default() is the stream init() opened, with the same
verify and query.
const log = await Tenbri.open('./audit', { key: 'sha256:ebe4f959…' })const report = await log.verify() // report.ok, report.entries, report.brokenconst refunds = await log.query({ action: 'issue_refund', target: 'stripe:ch_123' })const byInput = await log.query({ inputs: ['claude:tool_input@toolu_01'] })From inside the agent’s process, await defaultStream() is the stream init() opened.
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.
Test your agent offline
Section titled “Test your agent offline”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.
curl -fsSLO https://raw.githubusercontent.com/tenbri/tenbri/main/adapters/claude-agent-sdk/testing/fake-claude.mjschmod +x fake-claude.mjsFAKE_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:
import asyncioimport jsonfrom pathlib import Path
import tenbrifrom claude_agent_sdk import ClaudeAgentOptions, query
FAKE = Path(__file__).parent / "fake-claude.mjs" # copied from the Tenbri repositoryREFUNDS = {"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).okpython -m pytest -q test_agent.py. [100%]1 passed in 0.67simport { test } from 'node:test'import assert from 'node:assert/strict'import { mkdtempSync } from 'node:fs'import { tmpdir } from 'node:os'import { join } from 'node:path'import { fileURLToPath } from 'node:url'import { query } from '@anthropic-ai/claude-agent-sdk'import { init } from 'tenbri'import { instrumentClaudeAgentSdk } from '@tenbri/claude-agent-sdk'
const FAKE = fileURLToPath(new URL('./fake-claude.mjs', import.meta.url)) // copied from the Tenbri repositoryconst actions = { issue_refund: { target: 'stripe:{charge}', policy: 'policy:refund@14' } }
/** Runs the real SDK against the fake CLI, which plays `calls` as the model's tool calls. */async function runAgent(calls: object[]) { const options = instrumentClaudeAgentSdk( { pathToClaudeCodeExecutable: FAKE, executable: 'node', env: { ...process.env, FAKE_CLAUDE_SCENARIO: JSON.stringify(calls) }, }, { actions }, ) for await (const _ of query({ prompt: 'Refund ch_123', options })) { }}
test('each tool call is recorded', async () => { // A stream and keys of its own: the test never touches your real keys directory. const dir = mkdtempSync(join(tmpdir(), 'audit-')) const log = await init({ stream: join(dir, 'audit'), keys: join(dir, 'keys'), agent: 'agent:support' }) await runAgent([ { tool: 'mcp__billing__issue_refund', input: { charge: 'ch_123' }, id: 't1', result: 'ok' }, { tool: 'Bash', input: { command: 'make' }, id: 't2', result: 'error' }, ]) const entries = (await log.query({ principal: 'agent:support' })).map((m) => m.entry) assert.deepEqual( entries.map((e) => [e.action?.type, e.phase, e.status]), [ ['issue_refund', 'intent', undefined], ['issue_refund', 'outcome', 'ok'], ['Bash', 'intent', undefined], ['Bash', 'outcome', 'error'], ], ) assert.equal(entries[0].action?.target, 'stripe:ch_123') assert.equal(entries[0].rule?.ref, 'policy:refund') assert.ok((await log.verify(log.fingerprint!)).ok) await log.close()})node --test agent.test.ts✔ each tool call is recorded (182.883375ms)ℹ tests 1ℹ pass 1ℹ fail 0Troubleshooting
Section titled “Troubleshooting”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.
What is written
Section titled “What is written”| 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.
Advanced: lower-level API
Section titled “Advanced: lower-level API”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.
import asyncio
from claude_agent_sdk import ClaudeAgentOptions, queryfrom tenbri import AsyncTenbrifrom 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())import { query } from '@anthropic-ai/claude-agent-sdk'import { Tenbri } from 'tenbri'import { TenbriHooks, defaultDescribe } from '@tenbri/claude-agent-sdk'
const log = await Tenbri.open('./audit', { create: true })const hooks = new TenbriHooks(log, { principal: 'agent:support', approver: 'person:alice', describe: (tool, input) => tool === 'mcp__billing__issue_refund' ? { action: 'issue_refund', target: `stripe:${input.charge}` } : defaultDescribe(tool, input), // file and web tools: their path or URL})const options = hooks.instrument({ allowedTools: ['mcp__billing__issue_refund'] })for await (const _ of query({ prompt: 'Refund ch_123', options })) {}console.log((await log.query({ principal: 'agent:support' })).map((m) => [m.entry.phase, m.entry.action]))await log.close()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.
