Bill clients for Claude Code work: per-client time, tokens and proof of work, exported as invoice-ready logs

A Claude Code mod for freelancers and agencies: it bills your Claude Code work to clients.
It records which client each task was for, how long it took, how many tokens it used and what was done (files changed, commands run). Then it exports an invoice-ready work log per client and month.
/client Acme sets who you're billing. The folder is remembered, so the next session there starts as Acme. /client pause, /client resume and /client off also work, and /client alone lists your clients.● Acme · today 1h 05m · 84k tok · 3 tasks · 108.00 USD, with Pause and Ledger buttons./ledger shows per-client totals for today, this week and this month, with the billable amount./ledger export [client] [YYYY-MM] writes ledger-<client>-<month>.md (a dated task list with hours, amount and proof) and a matching .csv for spreadsheets and accounting tools.You need Claude Code 2.1.287 or later (mods support).
git clone https://github.com/ahmedgcompany-cyber/client-ledger
claude --plugin-dir ./client-ledger
Or add the folder to CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json (env block) to load it in every session, the desktop app included.
These are set in /config, or under pluginConfigs.client-ledger.options in settings.
| Setting | Default | Meaning |
|---|---|---|
hourlyRate | 0 | What you bill per tracked hour |
currency | USD | Currency code on exports |
tokenRatePerMillion | 0 | What you bill per million tokens, on top of hours |
idleMinutes | 10 | Gaps between prompts count at most this long as your own time |
exportDir | ledger-exports | Where exports are written |
Entries are saved as JSON lines, one file per day, in ~/.claude/client-ledger/YYYY-MM-DD.jsonl. Nothing leaves your machine.
idleMinutes.claude plugin validate .
claude plugin test .
MIT
hooks/register.tsx 280 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Entry } from '../types'
5import {
6 addCommand,
7 addFile,
8 addUsage,
9 amount,
10 cleanClient,
11 dayKey,
12 emptyTotals,
13 finish,
14 fmtDuration,
15 fmtTokens,
16 monthKey,
17 monthStart,
18 newEntry,
19 parseJsonl,
20 slug,
21 summarize,
22 toCsv,
23 toMarkdown,
24 totalsOf,
25 weekStart,
26 type Billing,
27} from './ledger'
28
29const PANE = 'client-ledger'
30const active = atom({ plugin: 'client-ledger', key: 'active' } as const, { client: null, paused: false })
31const current = atom({ plugin: 'client-ledger', key: 'current' } as const, null)
32const today = atom({ plugin: 'client-ledger', key: 'today' } as const, emptyTotals())
33const summary = atom({ plugin: 'client-ledger', key: 'summary' } as const, [])
34
35type Engine = EngineInterface
36
37let lastEnd: number | null = null
38
39async function ledgerDir($: Engine): Promise<string> {
40 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '.'
41 return `${home.replace(/\\/g, '/')}/.claude/client-ledger`
42}
43
44async function readText($: Engine, path: string): Promise<string> {
45 try {
46 return await $.fs.read(path)
47 } catch {
48 return ''
49 }
50}
51
52/** Every entry in day files from `since` on. */
53async function loadSince($: Engine, since: number): Promise<Entry[]> {
54 const dir = await ledgerDir($)
55 const files = await $.fs.list(dir).catch(() => [])
56 const first = dayKey(since)
57 const names = files
58 .map(f => f.name)
59 .filter(n => /^\d{4}-\d{2}-\d{2}\.jsonl$/.test(n) && n.slice(0, 10) >= first)
60 const texts = await Promise.all(names.map(n => readText($, `${dir}/${n}`)))
61 return texts.flatMap(parseJsonl)
62}
63
64async function loadMonth($: Engine, month: string): Promise<Entry[]> {
65 const dir = await ledgerDir($)
66 const files = await $.fs.list(dir).catch(() => [])
67 const names = files.map(f => f.name).filter(n => n.startsWith(`${month}-`) && n.endsWith('.jsonl'))
68 const texts = await Promise.all(names.map(n => readText($, `${dir}/${n}`)))
69 return texts.flatMap(parseJsonl)
70}
71
72async function appendEntry($: Engine, entry: Entry): Promise<void> {
73 const path = `${await ledgerDir($)}/${dayKey(entry.start)}.jsonl`
74 const before = await readText($, path)
75 await $.fs.write(path, before + JSON.stringify(entry) + '\n')
76}
77
78async function refreshToday($: Engine): Promise<void> {
79 const { client } = await read($, active)
80 const now = await $.clock.now()
81 const path = `${await ledgerDir($)}/${dayKey(now)}.jsonl`
82 const mine = parseJsonl(await readText($, path)).filter(e => e.client === client)
83 await update($, today, () => totalsOf(mine))
84}
85
86async function setClient($: Engine, client: string | null): Promise<void> {
87 await update($, active, a => ({ ...a, client, paused: false }))
88 await update($, current, () => null)
89 const cwd = await $.session.cwd()
90 const folders = ((await $.store.get('folders')) ?? {}) as Record<string, string>
91 if (client === null) delete folders[cwd]
92 else folders[cwd] = client
93 await $.store.set('folders', folders)
94 if (client !== null) {
95 const clients = ((await $.store.get('clients')) ?? []) as string[]
96 if (!clients.includes(client)) await $.store.set('clients', [...clients, client].sort())
97 }
98 await refreshToday($)
99}
100
101async function openLedger($: Engine): Promise<void> {
102 const now = await $.clock.now()
103 const entries = await loadSince($, Math.min(weekStart(now), monthStart(now)))
104 await update($, summary, () => summarize(entries, now))
105 await $.ui.open({ id: PANE, title: 'Client Ledger' })
106}
107
108export const register: Register = (on, options) => {
109 const billing: Billing = {
110 hourlyRate: Number(options.hourlyRate ?? 0),
111 tokenRatePerMillion: Number(options.tokenRatePerMillion ?? 0),
112 currency: String(options.currency ?? 'USD'),
113 }
114 const idleMinutes = Number(options.idleMinutes ?? 10)
115 const exportDir = String(options.exportDir ?? '').trim() || 'ledger-exports'
116
117 on('session.start', async ($, e, next) => {
118 await $.command.register({
119 name: 'client',
120 description: 'Set the client this work is billed to',
121 argumentHint: '[name | off | pause | resume]',
122 })
123 await $.command.register({
124 name: 'ledger',
125 description: 'Show billed time per client, or export a client’s month',
126 argumentHint: '[export [client] [YYYY-MM]]',
127 })
128 const { client } = await read($, active)
129 if (client === null) {
130 const folders = ((await $.store.get('folders')) ?? {}) as Record<string, string>
131 const mapped = folders[await $.session.cwd()]
132 if (mapped !== undefined) await update($, active, a => ({ ...a, client: mapped }))
133 }
134 await refreshToday($)
135
136 return next(e)
137 })
138
139 on('command.run', { command: 'client' }, async ($, e) => {
140 const arg = e.args.trim()
141 const { client, paused } = await read($, active)
142
143 if (arg === '') {
144 const clients = ((await $.store.get('clients')) ?? []) as string[]
145 const now = client === null ? 'No client set.' : `Billing ${client}${paused ? ' (paused)' : ''}.`
146 const known = clients.length === 0 ? 'No clients yet: /client <name> adds one.' : `Clients: ${clients.join(', ')}`
147 return { text: `${now}\n${known}` }
148 }
149 if (arg === 'off') {
150 await setClient($, null)
151 return { text: 'Stopped tracking. This folder is no longer tied to a client.' }
152 }
153 if (arg === 'pause' || arg === 'resume') {
154 if (client === null) return { text: 'No client set: /client <name> first.' }
155 await update($, active, a => ({ ...a, paused: arg === 'pause' }))
156 return { text: arg === 'pause' ? `Paused billing ${client}.` : `Resumed billing ${client}.` }
157 }
158
159 const name = cleanClient(arg)
160 await setClient($, name)
161 return { text: `Billing ${name}. This folder will open as ${name} next time.` }
162 })
163
164 on('command.run', { command: 'ledger' }, async ($, e) => {
165 const words = e.args.trim().split(/\s+/).filter(w => w !== '')
166 if (words[0] !== 'export') {
167 await openLedger($)
168 return { text: 'Client Ledger opened.' }
169 }
170
171 const rest = words.slice(1)
172 const now = await $.clock.now()
173 const month = rest.length > 0 && /^\d{4}-\d{2}$/.test(rest[rest.length - 1]!) ? rest.pop()! : monthKey(now)
174 const client = rest.length > 0 ? cleanClient(rest.join(' ')) : (await read($, active)).client
175 if (client === null) return { text: 'Which client? /ledger export <client> [YYYY-MM]' }
176
177 const entries = await loadMonth($, month)
178 if (!entries.some(x => x.client === client)) return { text: `No tracked work for ${client} in ${month}.` }
179
180 const base = `${exportDir.replace(/\\/g, '/').replace(/\/$/, '')}/ledger-${slug(client)}-${month}`
181 await $.fs.write(`${base}.md`, toMarkdown(entries, client, month, billing))
182 await $.fs.write(`${base}.csv`, toCsv(entries, client, month, billing))
183 return { text: `Exported ${client}, ${month}:\n${base}.md\n${base}.csv` }
184 })
185
186 on('prompt.submit', async ($, e, next) => {
187 const { client, paused } = await read($, active)
188 const isOwnTurn = e.turnId === undefined && !e.text.trimStart().startsWith('/')
189 if (client !== null && !paused && isOwnTurn) {
190 const now = await $.clock.now()
191 await update($, current, () => newEntry(client, e.text, now, lastEnd, idleMinutes))
192 }
193
194 return next(e)
195 })
196
197 on('tool.call', async ($, e, next) => {
198 const ran = await next(e)
199 if ((await read($, current)) === null || ran.deny !== undefined) return ran
200
201 if ((e.tool === 'Edit' || e.tool === 'Write') && ran.isError !== true) {
202 await update($, current, c => (c === null ? c : addFile(c, e.file_path)))
203 } else if (e.tool === 'Bash') {
204 await update($, current, c => (c === null ? c : addCommand(c, e.command, ran.isError !== true)))
205 }
206
207 return ran
208 })
209
210 on('turn.complete', async ($, e, next) => {
211 const entry = await read($, current)
212 if (entry !== null) {
213 const u = e.usage
214 let done = u ? addUsage(entry, u.input_tokens + u.cache_creation_input_tokens, u.output_tokens, u.model) : entry
215 if (e.agentId === undefined) {
216 const now = await $.clock.now()
217 done = finish(done, e.durationMs, now)
218 lastEnd = now
219 await update($, current, () => null)
220 await appendEntry($, done)
221 await refreshToday($)
222 } else {
223 await update($, current, () => done)
224 }
225 }
226
227 return next(e)
228 })
229
230 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
231 const { client, paused } = await read($, active)
232 if (client === null || e.props.hasSurvey) return next(e)
233
234 const t = await read($, today)
235 const { Box, Button, Text } = $.ui.resolve(e)
236 const money = billing.hourlyRate > 0 || billing.tokenRatePerMillion > 0
237 ? ` · ${amount(t, billing).toFixed(2)} ${billing.currency}`
238 : ''
239
240 return (
241 <Box>
242 <Text color={paused ? undefined : 'green'} dimColor={paused}>
243 {paused ? '○' : '●'} {client}
244 </Text>
245 <Text dimColor>
246 {' '}· today {fmtDuration(t.ms)} · {fmtTokens(t.tokens)} tok · {t.tasks} tasks{money}{' '}
247 </Text>
248 <Button
249 key="pause"
250 label={paused ? 'Resume' : 'Pause'}
251 dimColor
252 onPress={() => update($, active, a => ({ ...a, paused: !a.paused }))}
253 />
254 <Button key="ledger" label="Ledger" dimColor onPress={() => openLedger($)} />
255 </Box>
256 )
257 })
258
259 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
260 const { Box, Text } = $.ui.resolve(e)
261 const rows = await read($, summary)
262 const { client } = await read($, active)
263
264 return (
265 <Box flexDirection="column">
266 <Text bold>Client Today Week Month Billable</Text>
267 {rows.length === 0 && <Text dimColor>No tracked work this month. Set a client with /client name.</Text>}
268 {rows.map(r => (
269 <Text color={r.client === client ? 'green' : undefined}>
270 {r.client.padEnd(15).slice(0, 15)} {fmtDuration(r.today.ms).padEnd(10)} {fmtDuration(r.week.ms).padEnd(10)}{' '}
271 {fmtDuration(r.month.ms).padEnd(10)} {amount(r.month, billing).toFixed(2)} {billing.currency}
272 </Text>
273 ))}
274 <Text dimColor> </Text>
275 <Text dimColor>/ledger export [client] [YYYY-MM] writes an invoice-ready log.</Text>
276 </Box>
277 )
278 })
279}
280hooks/ledger.ts 208 lines1import type { ClientSummary, Entry, Totals } from '../types'
2
3const DAY_MS = 86_400_000
4
5export type Billing = { hourlyRate: number; tokenRatePerMillion: number; currency: string }
6
7export const pad = (n: number): string => String(n).padStart(2, '0')
8
9/** Local calendar day, `YYYY-MM-DD`: the name of that day's ledger file. */
10export const dayKey = (ms: number): string => {
11 const d = new Date(ms)
12 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
13}
14
15export const monthKey = (ms: number): string => dayKey(ms).slice(0, 7)
16
17/** Start of the local day / Monday-based week / month that holds `ms`. */
18export const dayStart = (ms: number): number => {
19 const d = new Date(ms)
20 return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
21}
22export const weekStart = (ms: number): number => {
23 const day = (new Date(ms).getDay() + 6) % 7
24 return dayStart(ms) - day * DAY_MS
25}
26export const monthStart = (ms: number): number => {
27 const d = new Date(ms)
28 return new Date(d.getFullYear(), d.getMonth(), 1).getTime()
29}
30
31/** Normalises a client name typed by the person: trimmed, inner spaces collapsed. */
32export const cleanClient = (raw: string): string => raw.trim().replace(/\s+/g, ' ').slice(0, 60)
33
34export const slug = (s: string): string =>
35 s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'client'
36
37export const newEntry = (
38 client: string,
39 prompt: string,
40 now: number,
41 lastEnd: number | null,
42 idleMinutes: number,
43): Entry => {
44 const gap = lastEnd === null ? 0 : Math.max(0, now - lastEnd)
45 const firstLine = prompt.trim().split('\n')[0] ?? ''
46 return {
47 id: `${now.toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
48 client,
49 start: now,
50 end: now,
51 aiMs: 0,
52 humanMs: Math.min(gap, idleMinutes * 60_000),
53 tokensIn: 0,
54 tokensOut: 0,
55 task: firstLine.slice(0, 200),
56 files: [],
57 commands: [],
58 }
59}
60
61export const addFile = (entry: Entry, path: string): Entry =>
62 entry.files.includes(path) ? entry : { ...entry, files: [...entry.files, path].slice(-100) }
63
64export const addCommand = (entry: Entry, cmd: string, ok: boolean): Entry => ({
65 ...entry,
66 commands: [...entry.commands, { cmd: cmd.trim().split('\n')[0]!.slice(0, 160), ok }].slice(-100),
67})
68
69export const addUsage = (entry: Entry, tokensIn: number, tokensOut: number, model?: string): Entry => ({
70 ...entry,
71 tokensIn: entry.tokensIn + tokensIn,
72 tokensOut: entry.tokensOut + tokensOut,
73 model: model ?? entry.model,
74})
75
76export const finish = (entry: Entry, aiMs: number, now: number): Entry => ({
77 ...entry,
78 aiMs: entry.aiMs + aiMs,
79 end: now,
80})
81
82export const entryMs = (e: Entry): number => e.aiMs + e.humanMs
83export const entryTokens = (e: Entry): number => e.tokensIn + e.tokensOut
84
85export const emptyTotals = (): Totals => ({ ms: 0, tokens: 0, tasks: 0 })
86
87export const addToTotals = (t: Totals, e: Entry): Totals => ({
88 ms: t.ms + entryMs(e),
89 tokens: t.tokens + entryTokens(e),
90 tasks: t.tasks + 1,
91})
92
93export const totalsOf = (entries: readonly Entry[]): Totals => entries.reduce(addToTotals, emptyTotals())
94
95/** Per-client totals for today, this week and this month, busiest month first. */
96export const summarize = (entries: readonly Entry[], now: number): ClientSummary[] => {
97 const d = dayStart(now)
98 const w = weekStart(now)
99 const m = monthStart(now)
100 const by = new Map<string, ClientSummary>()
101 for (const e of entries) {
102 if (e.start < m) continue
103 const s = by.get(e.client) ?? { client: e.client, today: emptyTotals(), week: emptyTotals(), month: emptyTotals() }
104 s.month = addToTotals(s.month, e)
105 if (e.start >= w) s.week = addToTotals(s.week, e)
106 if (e.start >= d) s.today = addToTotals(s.today, e)
107 by.set(e.client, s)
108 }
109 return [...by.values()].sort((a, b) => b.month.ms - a.month.ms)
110}
111
112export const parseJsonl = (text: string): Entry[] =>
113 text
114 .split('\n')
115 .filter(line => line.trim() !== '')
116 .flatMap(line => {
117 try {
118 return [JSON.parse(line) as Entry]
119 } catch {
120 return []
121 }
122 })
123
124export const fmtDuration = (ms: number): string => {
125 const mins = Math.round(ms / 60_000)
126 const h = Math.floor(mins / 60)
127 const m = mins % 60
128 return h > 0 ? `${h}h ${pad(m)}m` : `${m}m`
129}
130
131export const fmtTokens = (n: number): string =>
132 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
133
134export const hours = (ms: number): number => Math.round((ms / 3_600_000) * 100) / 100
135
136export const amount = (t: Totals, b: Billing): number =>
137 Math.round((hours(t.ms) * b.hourlyRate + (t.tokens / 1_000_000) * b.tokenRatePerMillion) * 100) / 100
138
139const fmtTime = (ms: number): string => {
140 const d = new Date(ms)
141 return `${pad(d.getHours())}:${pad(d.getMinutes())}`
142}
143
144/** Invoice-ready work log for one client and month, newest last. */
145export const toMarkdown = (entries: readonly Entry[], client: string, month: string, b: Billing): string => {
146 const mine = entries.filter(e => e.client === client && monthKey(e.start) === month).sort((x, y) => x.start - y.start)
147 const t = totalsOf(mine)
148 const lines: string[] = [
149 `# Work log: ${client}, ${month}`,
150 '',
151 `| Tasks | Time | Hours | Tokens | Amount |`,
152 `| --- | --- | --- | --- | --- |`,
153 `| ${t.tasks} | ${fmtDuration(t.ms)} | ${hours(t.ms)} | ${fmtTokens(t.tokens)} | ${amount(t, b).toFixed(2)} ${b.currency} |`,
154 '',
155 ]
156 if (b.hourlyRate > 0 || b.tokenRatePerMillion > 0) {
157 lines.push(
158 `Rates: ${b.hourlyRate} ${b.currency}/hour` +
159 (b.tokenRatePerMillion > 0 ? `, ${b.tokenRatePerMillion} ${b.currency} per million tokens` : ''),
160 '',
161 )
162 }
163 let day = ''
164 for (const e of mine) {
165 const dk = dayKey(e.start)
166 if (dk !== day) {
167 day = dk
168 lines.push(`## ${dk}`, '')
169 }
170 lines.push(`### ${fmtTime(e.start)} · ${fmtDuration(entryMs(e))} · ${e.task || '(no description)'}`)
171 for (const f of e.files) lines.push(`- changed \`${f}\``)
172 for (const c of e.commands) lines.push(`- ran \`${c.cmd}\`${c.ok ? '' : ' (failed)'}`)
173 lines.push('')
174 }
175 if (mine.length === 0) lines.push('_No tracked work this month._', '')
176 return lines.join('\n')
177}
178
179const csvCell = (v: string | number): string => {
180 const s = String(v)
181 return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
182}
183
184export const toCsv = (entries: readonly Entry[], client: string, month: string, b: Billing): string => {
185 const header = ['date', 'start', 'end', 'hours', 'ai_minutes', 'human_minutes', 'tokens', 'amount', 'currency', 'task', 'files', 'commands']
186 const rows = entries
187 .filter(e => e.client === client && monthKey(e.start) === month)
188 .sort((x, y) => x.start - y.start)
189 .map(e => {
190 const t = addToTotals(emptyTotals(), e)
191 return [
192 dayKey(e.start),
193 fmtTime(e.start),
194 fmtTime(e.end),
195 hours(t.ms),
196 Math.round(e.aiMs / 60_000),
197 Math.round(e.humanMs / 60_000),
198 t.tokens,
199 amount(t, b).toFixed(2),
200 b.currency,
201 e.task,
202 e.files.join(' '),
203 e.commands.map(c => c.cmd).join(' ; '),
204 ].map(csvCell).join(',')
205 })
206 return [header.join(','), ...rows].join('\n') + '\n'
207}
208types/index.d.ts 43 lines1/** One prompt's worth of work, attributed to a client. */
2export type Entry = {
3 id: string
4 client: string
5 /** Prompt submitted, ms since epoch. */
6 start: number
7 /** Turn finished, ms since epoch. */
8 end: number
9 /** Time Claude spent working on the turn. */
10 aiMs: number
11 /** Your own time before the prompt (reviewing, typing), capped by the idle setting. */
12 humanMs: number
13 tokensIn: number
14 tokensOut: number
15 model?: string
16 /** First line of the prompt, cut to 200 characters. */
17 task: string
18 files: string[]
19 commands: { cmd: string; ok: boolean }[]
20}
21
22export type Totals = { ms: number; tokens: number; tasks: number }
23
24export type ClientSummary = {
25 client: string
26 today: Totals
27 week: Totals
28 month: Totals
29}
30
31export type Active = { client: string | null; paused: boolean }
32
33declare module 'claude-code' {
34 interface PluginState {
35 'client-ledger': {
36 active: Active
37 current: Entry | null
38 today: Totals
39 summary: ClientSummary[]
40 }
41 }
42}
43