Converts PDF, Word, Excel and PowerPoint files to Markdown with markitdown before reading them

A mod for Claude Code: it converts PDF, Word, Excel and PowerPoint files to Markdown with markitdown before Claude reads them.
Reading the Markdown instead of the original spends fewer tokens and is easier to understand.
.pdf, .docx, .xlsx, .xls or .pptx, the mod converts it and hands over the Markdown in its place.report.pdf.md, and is reused as long as the original does not change.| Command | What it does |
|---|---|
/converter | The mod's status and how many documents it converted, reused or could not convert in the session. |
/converter off | Pauses it: documents are read in their original format. |
/converter on | Resumes it. |
py launcher, the one Python installs on Windows.markitdown package:py -m pip install "markitdown[all]"
At the prompt of a terminal session:
/plugin install converter --marketplace zrdqns/claude-code-converter
Answer y to add the marketplace and choose the scope (the user scope loads it in every session, including the desktop app's).
To try it from a local copy, without installing it:
claude --plugin-dir ./claude-code-converter
claude plugin validate .
claude plugin test .
The module is in hooks/register.ts, its state contract in types/index.d.ts and the tests in tests/.
hooks/register.ts 126 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4const DOCUMENT = /\.(pdf|docx|xlsx|xls|pptx)$/i
5const CONVERT_MS = 120000
6// Under this many bytes the conversion found no text (a scanned PDF): the
7// original reads better.
8const EMPTY_BYTES = 64
9const MAX_FAILURES = 100
10const SCRIPT = [
11 'import sys',
12 'from markitdown import MarkItDown',
13 'text = MarkItDown().convert(sys.argv[1]).text_content',
14 "open(sys.argv[2], 'w', encoding='utf-8').write(text)",
15].join('\n')
16
17const failures = atom({ plugin: 'converter', key: 'failures' } as const, {})
18const tally = atom({ plugin: 'converter', key: 'tally' } as const, {
19 converted: 0,
20 reused: 0,
21 failed: 0,
22})
23const isPaused = atom({ plugin: 'converter', key: 'isPaused' } as const, false)
24const notice = atom({ plugin: 'converter', key: 'notice' } as const, null)
25
26const baseName = (path: string) =>
27 path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
28
29export const register: Register = on => {
30 on('session.start', async ($, e, next) => {
31 await $.command.register({
32 name: 'converter',
33 description:
34 'Document converter status; "off" pauses it and "on" resumes it',
35 })
36
37 return next(e)
38 })
39
40 on('command.run', { command: 'converter' }, async ($, e) => {
41 const word = e.args.trim().toLowerCase()
42 if (word === 'off' || word === 'on') {
43 await update($, isPaused, () => word === 'off')
44 }
45 const [paused, sum] = await Promise.all([read($, isPaused), read($, tally)])
46
47 return {
48 text:
49 `Converter ${paused ? 'paused' : 'active'}. This session: ` +
50 `${sum.converted} converted, ${sum.reused} reused, ${sum.failed} failed. ` +
51 (paused ? '/converter on resumes it.' : '/converter off pauses it.'),
52 }
53 })
54
55 on('tool.call', { tool: 'Read' }, async ($, e, next) => {
56 if (!DOCUMENT.test(e.file_path)) return next(e)
57 if (await read($, isPaused)) return next(e)
58 const source = await $.fs.stat(e.file_path).catch(() => undefined)
59 if (source === undefined || source.kind !== 'file') return next(e)
60
61 const target = `${e.file_path}.md`
62 const name = baseName(e.file_path)
63 let made = await $.fs.stat(target).catch(() => undefined)
64 const isFresh =
65 made !== undefined && made.kind === 'file' && made.mtimeMs >= source.mtimeMs
66
67 if (!isFresh) {
68 if ((await read($, failures))[e.file_path] === source.mtimeMs) {
69 return next(e)
70 }
71 const ran = await $.process
72 .run(['py', '-c', SCRIPT, e.file_path, target], {
73 timeoutMs: CONVERT_MS,
74 })
75 .catch(() => undefined)
76 made =
77 ran !== undefined && ran.exitCode === 0
78 ? await $.fs.stat(target).catch(() => undefined)
79 : undefined
80
81 if (made === undefined || made.kind !== 'file') {
82 await update($, failures, all => ({
83 ...Object.fromEntries(Object.entries(all).slice(1 - MAX_FAILURES)),
84 [e.file_path]: source.mtimeMs,
85 }))
86 await update($, tally, sum => ({ ...sum, failed: sum.failed + 1 }))
87 const at = await $.clock.now()
88 await update($, notice, () => ({
89 text: `Could not convert ${name}; the original was read`,
90 at,
91 }))
92
93 return next(e)
94 }
95 }
96 if (made === undefined || made.size < EMPTY_BYTES) return next(e)
97
98 await update($, tally, sum =>
99 isFresh
100 ? { ...sum, reused: sum.reused + 1 }
101 : { ...sum, converted: sum.converted + 1 },
102 )
103 if (!isFresh) {
104 const at = await $.clock.now()
105 await update($, notice, () => ({
106 text: `${name} converted to Markdown`,
107 at,
108 }))
109 }
110
111 // A page range means nothing in the Markdown; offset and limit still do.
112 const { pages: _pages, ...rest } = e
113 const ran = await next({ ...rest, file_path: target })
114 if (ran.deny !== undefined) return ran
115
116 return {
117 ...ran,
118 context: [
119 ...(ran.context ?? []),
120 `This result is ${target}: the Markdown conversion of ${e.file_path} made with markitdown. ` +
121 "The original's images and layout are missing; if they are needed, /converter off lets the original be read.",
122 ],
123 }
124 })
125}
126types/index.d.ts 17 lines1export type Tally = { converted: number; reused: number; failed: number }
2
3/** The last thing worth telling the person, for a pane to show. */
4export type Notice = { text: string; at: number }
5
6declare module 'claude-code' {
7 interface PluginState {
8 converter: {
9 /** Sources markitdown could not convert, by path, with their mtime then. */
10 failures: Record<string, number>
11 tally: Tally
12 isPaused: boolean
13 notice: Notice | null
14 }
15 }
16}
17