SLOPSHOPPER

Tool Ledger

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.

newpanebandguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tool-ledger
│ ┃ Tool calls ✕ › fix the failing auth test and add an audit log call │ ┃ Tool calls · 9 · 1 error [ Errors only ] │ ┃ done Read src/auth.ts … ⏺ Read(src/auth.ts) │ ┃ done Grep refresh\( … ⎿ Read 6 lines │ ┃ done Edit src/auth.ts … ⏺ Update(src/auth.ts) │ ┃ done Write src/audit.ts … ⎿ Added 2 lines, removed 1 line │ ┃ done Write src/cache.ts … ⏺ Bash(bun test) │ ┃ error Bash bun test … ⎿ 3 pass, 1 fail │ ┃ done Bash git status --porcelain … │ ┃ done Bash rm -rf build && git push --… ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ done Bash cat .env … │ ✻ Worked for 42s · done 4:20 PM │ │ › /tool-ledger │ ⎿ tool-ledger: Tool Ledger opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Tool calls
Tool calls · 9 · 1 error [ Errors only ] done Read src/auth.ts 0.0s done Grep refresh\( 0.0s done Edit src/auth.ts 0.0s done Write src/audit.ts 0.0s done Write src/cache.ts 0.0s error Bash bun test 0.0s done Bash git status --porcelain 0.0s done Bash rm -rf build && git push --force o… 0.0s done Bash cat .env 0.0s
README

📒 Tool Ledger

Version Works in Category Claude Code License: MIT

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-ledger and see, call by call, what Claude and each of its subagents actually ran, and which calls your hooks or permission rules stopped.

✨ Why use it

  • Audit what subagents really did — their calls are grouped under each subagent's type and task, not buried in a collapsed transcript.
  • See your guardrails work — a call that a PreToolUse hook or a permission rule refuses shows as denied, apart from calls that failed on their own.
  • Spot failures fast — Errors only narrows the panel to failed and denied calls, with a running count in the header.
  • Safe to share your screen — one option hides commands, paths and patterns and keeps only tool names.
  • Never in the way — it only observes: it doesn't block, rewrite or approve a call, and if the ledger itself fails, the call still runs once with its own result.

🧩 What's inside

ComponentNameWhat it doesHow it runs
🧩 Modtool-ledgerRecords each tool call with its status and duration, draws the ledger panel and adds the /tool-ledger commandAutomatically in every Claude Code session once installed; the panel opens with /tool-ledger

✅ Requirements

RequirementDetails
Claude Codev2.1.295 or later — check with claude --version. Tested with v2.1.308.
SurfacesSee Compatibility
External toolsNone
Accounts / API keysNone

[!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, /resume and /branch start it empty. The panel shows what the transcript already shows.

📥 Installation

# 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>.

🚀 Usage

Example: open the ledger

/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.

ToolWhat the row shows
BashThe first 60 characters of the command
Read, Edit, WriteThe file path, relative to the working directory
Grep, GlobThe pattern
AgentThe task description
MCP toolsserver › tool
Any other toolOnly its name

Example: see only what went wrong

Press Errors only in the panel. It shows failed and denied calls, and the button becomes All calls.

Example: keep the ledger open beside the transcript

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.

⚙️ Configuration

OptionTypeDefaultDescription
autoOpennever or sidebarneversidebar opens the panel by itself, once per session, where it can dock beside the transcript, such as a fullscreen terminal from 144 columns
showTargetsbooleantrueTurn it off to hide commands, paths, patterns and subagent tasks, for example while you share your screen
maxRowsnumber, 10 to 2000500How 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.

🔄 Updating

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.

🗑️ Uninstalling

claude plugin uninstall tool-ledger@the-claude-factory

In a session: /plugin → Installed → Tool Ledger → Uninstall.

🧭 Compatibility

Works in: 💻 Claude Code only

ComponentClaude CodeClaude 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.

Limitations

  • Denied covers a { 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.
  • A PreToolUse hook in managed settings runs ahead of every mod, so a call it denies shows as error.
  • A call that is interrupted before it returns can stay at runs.
  • If the plugin reloads in the middle of a call, such as when you update it, that call's row may not close.
  • Claude Code's own internal tools, such as the one a subagent uses to hand its report back, appear as rows too.
  • These settings turn the mod off: disableAllHooks, allowManagedModsOnly and allowManagedHooksOnly in managed settings, and starting Claude Code with --safe-mode or --bare.
  • In organizations with managed settings or a Team or Enterprise login, Claude Code's built-in guard runs ahead of every mod and can refuse to load it.

❓ FAQ & Troubleshooting

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.

🔗 Related

🐞 Found a problem?

Report a bug • Request a feature • Ask a question

📝 Changelog

See CHANGELOG.md for every release.

📄 License

MIT © Nery Murillo

Source 2 files
hooks/register.tsx 370 lines
1import 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}
370
types/index.d.ts 29 lines
1/** 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