Lists every tool call of the session in a live panel: what ran, on what, whether Claude or a subagent made it, and whether it finished, failed or was denied.

Why use it • What's inside • Install • Usage • Compatibility • FAQ
💻 Claude Code only — it is a mod: it draws a panel in Claude Code's own interface, which the Claude app's chat and Cowork don't load.
[!TIP] TL;DR — Type
/tool-ledgerand see, call by call, what Claude and each of its subagents actually ran, and which calls your hooks or permission rules stopped.
PreToolUse hook or a permission rule refuses shows as denied, apart from calls that failed on their own.| Component | Name | What it does | How it runs |
|---|---|---|---|
| 🧩 Mod | tool-ledger | Records each tool call with its status and duration, draws the ledger panel and adds the /tool-ledger command | Automatically in every Claude Code session once installed; the panel opens with /tool-ledger |
| Requirement | Details |
|---|---|
| Claude Code | v2.1.295 or later — check with claude --version. Tested with v2.1.308. |
| Surfaces | See Compatibility |
| External tools | None |
| Accounts / API keys | None |
[!NOTE] Tool Ledger makes no network calls, runs no commands, and reads or writes no files. The ledger lives only in the session's memory: nothing is saved to disk or carried to the next session, and
/clear,/resumeand/branchstart it empty. The panel shows what the transcript already shows.
# One step: adds the marketplace (if needed) and installs the plugin — Claude Code v2.1.292+
claude plugin install tool-ledger --marketplace nerymurillohnd/the-claude-factory
# Or, with the marketplace already added
claude plugin install tool-ledger@the-claude-factory
Add --scope project to enable it for everyone working in the current repository; each collaborator still installs it once on their own machine.
/plugin install tool-ledger --marketplace nerymurillohnd/the-claude-factory
Requires Claude Code v2.1.275+. With the marketplace already added, /plugin install tool-ledger@the-claude-factory opens the plugin's details so you can pick a scope. You can also browse it in /plugin → Discover.
With the marketplace added, click + next to the prompt box → Add plugins (or Plugins → Browse plugins), or open Settings → Plugins → Discover; then select Tool Ledger and choose a scope.
[!NOTE] New to this marketplace? The installation guide covers every surface step by step.
A mod runs with your permissions and no sandbox. Before you install it, you can see everything it hooks and calls with claude plugin validate <path to the plugin folder>.
/tool-ledger
Result: a panel that keeps up with the session:
Tool calls · 142 · 3 running · 2 errors · 1 denied [ Errors only ]
done Read src/lib/catalog.ts 0.1s
done Bash pnpm test --filter api 14.2s
error Bash tsc -p . 6.0s
denied Read .env
▸ Explore · "Find pricing references"
done MCP docs › search 0.3s
runs Read docs/pricing.md
Each row shows the status, the tool, what it acted on and how long it took. The panel shows the latest rows that fit.
| Tool | What the row shows |
|---|---|
| Bash | The first 60 characters of the command |
| Read, Edit, Write | The file path, relative to the working directory |
| Grep, Glob | The pattern |
| Agent | The task description |
| MCP tools | server › tool |
| Any other tool | Only its name |
Press Errors only in the panel. It shows failed and denied calls, and the button becomes All calls.
claude plugin install tool-ledger@the-claude-factory --config autoOpen=sidebar
Result: where the panel can dock beside the transcript, it opens by itself once per session: in a fullscreen terminal from 144 columns wide, or on another surface that reports it docks panels. When you open it yourself with /tool-ledger, it fits any width.
| Option | Type | Default | Description |
|---|---|---|---|
autoOpen | never or sidebar | never | sidebar opens the panel by itself, once per session, where it can dock beside the transcript, such as a fullscreen terminal from 144 columns |
showTargets | boolean | true | Turn it off to hide commands, paths, patterns and subagent tasks, for example while you share your screen |
maxRows | number, 10 to 2000 | 500 | How many of the latest calls the session keeps |
Change them in /config, where autoOpen is a picker (v2.1.271+), or in /plugin → Installed → Tool Ledger → Configure options. From the shell, pass --config <option>=<value> to claude plugin install.
claude plugin update tool-ledger@the-claude-factory
Or in a session: /plugin → Installed → Tool Ledger → Update now. Then run /reload-plugins.
[!TIP] Turn on auto-update once for the whole marketplace:
/plugin→ Marketplaces → the-claude-factory → Enable auto-update.
claude plugin uninstall tool-ledger@the-claude-factory
In a session: /plugin → Installed → Tool Ledger → Uninstall.
Works in: 💻 Claude Code only
| Component | Claude Code | Claude app |
|---|---|---|
| 🧩 Mods | ✅ every Claude Code session except a Desktop session on WSL | ⚠️ the plugin directory lists a mod for Claude Code only |
<sub>✅ works · ⚠️ partial · 🔗 manual step · ➖ ignored · ❌ the plugin can't be installed there. The Claude app (web, desktop and mobile) runs a conversation as a Cowork session when it can, and as a chat conversation otherwise, for example on a device that can't run Cowork or in the separate Chat mode that Team and Enterprise plans keep for now.</sub>
The panel draws in the terminal, the desktop app's Code tab and the VS Code extension (extension v2.1.299+, or v2.1.304+ in a session that doesn't fetch feature flags, such as one on Amazon Bedrock). autoOpen doesn't open it in VS Code: open it there with /tool-ledger. In claude -p and cloud sessions nothing draws: the mod still records calls, but you can't see the ledger.
{ deny } from another mod, a PreToolUse hook in your settings, and a deny rule that refuses the call at the permission check, such as Bash(cat:*). A Read, Edit or Write of a path a deny rule covers is refused earlier, by the tool's own input check, so it shows as error. A call you decline at a permission prompt shows as error, and its duration includes the time the prompt waited for you.PreToolUse hook in managed settings runs ahead of every mod, so a call it denies shows as error.runs.disableAllHooks, allowManagedModsOnly and allowManagedHooksOnly in managed settings, and starting Claude Code with --safe-mode or --bare.The surface you are on can't place panels yet, or the terminal is too narrow for a panel that opened by itself. The reply names the reason. Panels show in the terminal, the desktop app's Code tab and the VS Code extension. Run /plugin to check that tool-ledger is listed among active mods; if it is missing, start Claude Code with claude --debug and look in the debug log for tool-ledger, where a line names why the module was not loaded or a hook was skipped.
It never changes them. Before each call it does a few quick bookkeeping steps in the session's memory (read the clock, add a row, and look up a subagent the first time it appears), and after the call it closes the row; the call itself always passes on unchanged. If recording fails, the call still runs exactly once and returns its own result. It never blocks or approves anything: keep using permission rules and hooks for that.
It hooks the start of each session (to add /tool-ledger), each tool call, the PreToolUse and permission-check decisions (to tell denied calls apart), the /tool-ledger command, the panel and the band above the prompt (to open the sidebar when you choose autoOpen). It calls only Claude Code's interface: the clock, the list of subagents, registering the command, the session's own memory, and opening and drawing the panel. It never touches files, processes, the network or the model. claude plugin validate lists the same hooks and calls.
Report a bug • Request a feature • Ask a question
See CHANGELOG.md for every release.
MIT © Nery Murillo
hooks/register.tsx 370 lines1import type { On, PluginOptions, Register } from 'claude-code'
2import { atom, read, update } from 'claude-code'
3
4import type { LedgerRow, LedgerStatus } from '../types'
5
6type Settings = {
7 autoOpen: 'never' | 'sidebar'
8 showTargets: boolean
9 maxRows: number
10}
11
12type Line = { key: string; text: string; status: LedgerStatus | null; header: Line | null }
13
14const COMMAND = 'tool-ledger'
15const PANE = 'tool-ledger'
16const TITLE = 'Tool calls'
17const COMMAND_CHARS = 60
18
19const rowsAtom = atom({ plugin: 'tool-ledger', key: 'rows' } as const, [])
20const agentsAtom = atom({ plugin: 'tool-ledger', key: 'agents' } as const, {})
21const errorsOnlyAtom = atom({ plugin: 'tool-ledger', key: 'isErrorsOnly' } as const, false)
22const autoOpenedAtom = atom({ plugin: 'tool-ledger', key: 'isAutoOpened' } as const, false)
23
24// The engine fills in the manifest's defaults; these only cover a value of the wrong type.
25const readSettings = (options: PluginOptions): Settings => {
26 const { autoOpen, showTargets, maxRows } = options
27 return {
28 autoOpen: autoOpen === 'sidebar' ? 'sidebar' : 'never',
29 showTargets: typeof showTargets === 'boolean' ? showTargets : true,
30 maxRows: typeof maxRows === 'number' && maxRows >= 1 ? Math.floor(maxRows) : 500,
31 }
32}
33
34const stringField = (input: object, key: string): string | undefined => {
35 const value: unknown = Reflect.get(input, key)
36 return typeof value === 'string' ? value : undefined
37}
38
39const relativeTo = (path: string, cwd: string): string => {
40 const base = cwd.endsWith('/') ? cwd : `${cwd}/`
41 return cwd !== '' && path.startsWith(base) ? path.slice(base.length) : path
42}
43
44// What a call acted on: a command, a path, a pattern, a task, or an MCP server and tool.
45export const targetOf = (input: object & { tool: string }, cwd: string): string | null => {
46 const { tool } = input
47 if (tool.startsWith('mcp__')) {
48 const [, server, ...name] = tool.split('__')
49 return `${server ?? ''} › ${name.join('__')}`
50 }
51 switch (tool) {
52 case 'Bash': {
53 const command = stringField(input, 'command')
54 return command === undefined ? null : command.replace(/\s+/g, ' ').slice(0, COMMAND_CHARS)
55 }
56 case 'Read':
57 case 'Edit':
58 case 'Write': {
59 const path = stringField(input, 'file_path')
60 return path === undefined ? null : relativeTo(path, cwd)
61 }
62 case 'Grep':
63 case 'Glob':
64 return stringField(input, 'pattern') ?? null
65 case 'Agent':
66 return stringField(input, 'description') ?? null
67 default:
68 return null
69 }
70}
71
72// "0.1s" and "14.2s" under a minute, then "2m 05s"; seconds are rounded before the minutes are
73// taken, so 119.6 s reads "2m 00s", never "1m 60s".
74export const formatDuration = (ms: number): string => {
75 const tenths = Math.round(ms / 100)
76 if (tenths < 600) {
77 return `${(tenths / 10).toFixed(1)}s`
78 }
79 const seconds = Math.round(ms / 1000)
80 return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, '0')}s`
81}
82
83const clip = (text: string, width: number): string =>
84 text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`
85
86const rowText = (row: LedgerRow, showTargets: boolean, columns: number, indent: number): string => {
87 const name = row.tool.startsWith('mcp__') ? 'MCP' : row.tool
88 const head = `${' '.repeat(indent)}${row.status.padEnd(6)} ${clip(name, 8).padEnd(8)} `
89 const duration = row.durationMs === null ? '' : ` ${formatDuration(row.durationMs)}`
90 const room = columns - head.length - duration.length
91 const target =
92 showTargets && row.target !== null ? clip(row.target, room).padEnd(room) : ''.padEnd(room)
93 return `${head}${target}${duration}`.trimEnd()
94}
95
96// The panel's lines: main-thread calls in order, each subagent's calls grouped under one
97// header placed where that subagent first appeared, then the last ones that fit.
98export const layout = (
99 rows: readonly LedgerRow[],
100 agents: Readonly<Record<string, string>>,
101 isErrorsOnly: boolean,
102 showTargets: boolean,
103 columns: number,
104 room: number,
105): Line[] => {
106 const shown = isErrorsOnly
107 ? rows.filter((r) => r.status === 'error' || r.status === 'denied')
108 : rows
109 const entries: ({ row: LedgerRow } | { agentId: string; rows: LedgerRow[] })[] = []
110 const groups = new Map<string, LedgerRow[]>()
111 for (const row of shown) {
112 if (row.agentId === null) {
113 entries.push({ row })
114 continue
115 }
116 const group = groups.get(row.agentId)
117 if (group === undefined) {
118 const fresh = [row]
119 groups.set(row.agentId, fresh)
120 entries.push({ agentId: row.agentId, rows: fresh })
121 } else {
122 group.push(row)
123 }
124 }
125
126 const lines: Line[] = []
127 for (const entry of entries) {
128 if ('row' in entry) {
129 lines.push({
130 key: entry.row.id,
131 text: rowText(entry.row, showTargets, columns, 0),
132 status: entry.row.status,
133 header: null,
134 })
135 continue
136 }
137 const label = agents[entry.agentId] ?? entry.agentId
138 // Without targets, keep only the subagent's type: its description can say as much as a target.
139 const header = showTargets ? label : (label.split(' · ')[0] ?? label)
140 const headerLine: Line = {
141 key: `agent-${entry.agentId}`,
142 text: clip(`▸ ${header}`, columns),
143 status: null,
144 header: null,
145 }
146 lines.push(headerLine)
147 for (const row of entry.rows) {
148 lines.push({
149 key: row.id,
150 text: rowText(row, showTargets, columns, 2),
151 status: row.status,
152 header: headerLine,
153 })
154 }
155 }
156 const kept = lines.slice(-Math.max(1, room))
157 // A subagent's rows never show without its header: it takes the place of the oldest kept row.
158 const first = kept[0]
159 if (first?.header != null) {
160 kept[0] = first.header
161 }
162 return kept
163}
164
165export const summary = (rows: readonly LedgerRow[]): string => {
166 const count = (status: LedgerStatus): number => rows.filter((r) => r.status === status).length
167 const parts = [`${TITLE} · ${rows.length}`]
168 const running = count('runs')
169 const errors = count('error')
170 const denied = count('denied')
171 if (running > 0) {
172 parts.push(`${running} running`)
173 }
174 if (errors > 0) {
175 parts.push(`${errors} error${errors === 1 ? '' : 's'}`)
176 }
177 if (denied > 0) {
178 parts.push(`${denied} denied`)
179 }
180 return parts.join(' · ')
181}
182
183const COLOR: Record<LedgerStatus, 'green' | 'red' | 'yellow' | undefined> = {
184 runs: undefined,
185 done: 'green',
186 error: 'red',
187 denied: 'yellow',
188}
189
190const colorOf = (status: LedgerStatus | null): { color?: 'green' | 'red' | 'yellow' } => {
191 const color = status === null ? undefined : COLOR[status]
192 return color === undefined ? {} : { color }
193}
194
195export const register: Register = (on: On, options: PluginOptions): void => {
196 const settings = readSettings(options)
197 let cwd = ''
198 let calls = 0
199 // tool_use_ids a settings PreToolUse hook or the permission check denied. Their deny
200 // reaches tool.call as an errored result, so the ledger learns of it here.
201 const deniedIds = new Set<string>()
202 // Set when autoOpen opened the panel; saved to $.state by the next tool call.
203 let isAutoOpened = false
204 let isAutoOpenSaved = false
205
206 // The ledger is best-effort: a failure here is dropped so it can never touch the call.
207 const quietly = async <T,>(work: () => Promise<T>): Promise<T | undefined> => {
208 try {
209 return await work()
210 } catch {
211 return undefined
212 }
213 }
214
215 on('session.start', async ($, e, next) => {
216 cwd = e.cwd
217 await quietly(() =>
218 $.command.register({
219 name: COMMAND,
220 description: 'Open the panel that lists every tool call',
221 }),
222 )
223 return next(e)
224 })
225
226 on('tool.call', async ($, e, next) => {
227 calls += 1
228 const id = e.tool_use_id ?? `call-${calls}`
229 const agentId = e.agentId ?? null
230 const startedAt = (await quietly(() => $.clock.now())) ?? null
231 if (isAutoOpened && !isAutoOpenSaved) {
232 isAutoOpenSaved = (await quietly(() => update($, autoOpenedAtom, () => true))) === true
233 }
234
235 await quietly(async () => {
236 if (agentId !== null && !(agentId in (await read($, agentsAtom)))) {
237 const info = (await $.agent.list()).find((agent) => agent.id === agentId)
238 if (info !== undefined) {
239 await update($, agentsAtom, (agents) => ({
240 ...agents,
241 [agentId]: `${info.type} · "${info.description}"`,
242 }))
243 }
244 }
245 })
246 const row: LedgerRow = {
247 id,
248 tool: e.tool,
249 target: targetOf(e, cwd),
250 agentId,
251 status: 'runs',
252 startedAt: startedAt ?? 0,
253 durationMs: null,
254 }
255 await quietly(() => update($, rowsAtom, (rows) => [...rows, row].slice(-settings.maxRows)))
256
257 // Always awaited: returning while next(e) is pending would abort the call.
258 const result = await next(e)
259
260 // A deny seen beneath counts only when the call did not run: a mod above this one can still
261 // allow it.
262 const wasDeniedBeneath = deniedIds.delete(id)
263 const status: LedgerStatus =
264 result.deny !== undefined || (wasDeniedBeneath && result.isError === true)
265 ? 'denied'
266 : result.isError === true
267 ? 'error'
268 : 'done'
269 const endedAt = startedAt === null ? undefined : await quietly(() => $.clock.now())
270 const durationMs =
271 startedAt === null || endedAt === undefined || status === 'denied'
272 ? null
273 : endedAt - startedAt
274 await quietly(() =>
275 update($, rowsAtom, (rows) =>
276 rows.map((r) => (r.id === id ? { ...r, status, durationMs } : r)),
277 ),
278 )
279
280 return result
281 }).catch((_$, e, next) => next(e))
282 // Replay-safe: when the hook already called next, this returns that call's result without
283 // running the tool again; when it had not, it runs the tool once.
284
285 on('classic.PreToolUse', async (_$, e, next) => {
286 const decision = await next(e)
287 if (decision.deny !== undefined && e.tool_use_id !== undefined) {
288 deniedIds.add(e.tool_use_id)
289 }
290 return decision
291 }).catch((_$, e, next) => next(e))
292
293 on('tool.check', async (_$, e, next) => {
294 const verdict = await next(e)
295 if (verdict.decision === 'deny' && e.tool_use_id !== undefined) {
296 deniedIds.add(e.tool_use_id)
297 }
298 return verdict
299 }).catch((_$, e, next) => next(e))
300
301 on('command.run', { command: COMMAND }, async ($) => {
302 const opened = await quietly(() => $.ui.open({ id: PANE, title: TITLE }))
303 if (opened === undefined) {
304 return { text: 'Tool Ledger could not open its panel.' }
305 }
306 return {
307 text: opened.isPlaced
308 ? 'Tool Ledger opened.'
309 : `Tool Ledger is open but not shown yet: ${opened.reason}`,
310 }
311 }).catch(() => ({ text: 'Tool Ledger could not open its panel.' }))
312
313 // The band above the prompt is the first place a surface says whether it docks panes.
314 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
315 if (settings.autoOpen === 'sidebar' && !isAutoOpened && e.viewport?.isFullscreen === true) {
316 // Once per session. A draw may read $.state but not write it, so the flag is saved by the
317 // next tool call, and a reload after that doesn't reopen a panel the user closed.
318 if ((await quietly(() => read($, autoOpenedAtom))) !== true) {
319 isAutoOpened = true
320 await quietly(() => $.ui.open({ id: PANE, title: TITLE }))
321 }
322 }
323 return next(e)
324 })
325
326 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
327 const rows = await read($, rowsAtom)
328 const agents = await read($, agentsAtom)
329 const isErrorsOnly = await read($, errorsOnlyAtom)
330 const { Box, Button, Text } = $.ui.resolve(e)
331 const lines = layout(
332 rows,
333 agents,
334 isErrorsOnly,
335 settings.showTargets,
336 e.props.bodyColumns,
337 e.props.scroll.bodyRows - 2,
338 )
339
340 return (
341 <Box flexDirection="column">
342 <Box flexDirection="row" gap={1}>
343 <Text weight="bold">{summary(rows)}</Text>
344 <Button
345 key="errors-only"
346 label={isErrorsOnly ? 'All calls' : 'Errors only'}
347 onPress={(): Promise<boolean> => update($, errorsOnlyAtom, (value) => !value)}
348 />
349 </Box>
350 {lines.length === 0 ? (
351 <Text dimColor={true}>
352 {isErrorsOnly ? 'No errors or denied calls.' : 'No tool calls yet.'}
353 </Text>
354 ) : (
355 lines.map((line) => (
356 <Text
357 key={line.key}
358 wrap="truncate"
359 dimColor={line.status === null}
360 {...colorOf(line.status)}
361 >
362 {line.text}
363 </Text>
364 ))
365 )}
366 </Box>
367 )
368 })
369}
370types/index.d.ts 29 lines1/** How a tool call stands: still running, finished, failed, or refused before it ran. */
2export type LedgerStatus = 'runs' | 'done' | 'error' | 'denied'
3
4/** One tool call, as the panel lists it. */
5export type LedgerRow = {
6 id: string
7 tool: string
8 /** What the call acted on (a command, a path, a pattern), or null for tools without one. */
9 target: string | null
10 /** The subagent that made the call, or null for the main thread. */
11 agentId: string | null
12 status: LedgerStatus
13 startedAt: number
14 durationMs: number | null
15}
16
17declare module 'claude-code' {
18 interface PluginState {
19 'tool-ledger': {
20 rows: LedgerRow[]
21 /** Each subagent's label, `type · "description"`, by agent id. */
22 agents: Record<string, string>
23 isErrorsOnly: boolean
24 /** Whether `autoOpen: sidebar` already opened the panel this session. */
25 isAutoOpened: boolean
26 }
27 }
28}
29