[DOCS] Hooks reference: say that hook stdin is UTF-8, because Python hooks on Windows decode it with the ANSI code page and silently corrupt non-ASCII input
Documentation Type
Missing documentation
Documentation Location
https://code.claude.com/docs/en/hooks (Hook input), and https://code.claude.com/docs/en/hooks-guide
Section/Topic
Hook input / common input fields
Current Documentation
The reference describes the JSON fields a hook receives on stdin. Neither page says what encoding that JSON is in.
What's Wrong or Missing?
Claude Code writes hook input as raw UTF-8: I captured a PostToolUse hook's stdin for a Write of 合言葉はさくら and the bytes were e5 90 88 e8 a8 80 ..., not \u escapes.
Python on Windows decodes sys.stdin with the ANSI code page unless UTF-8 mode is on, so json.load(sys.stdin) in a hook gets mojibake for any non-ASCII text. On a Japanese system (code page 932) I hit three silent failures in my own plugins, which all read stdin the usual way:
- a PostToolUse hook that records written lines failed to record a file with Japanese content (the decoded text contained lone surrogates and could not be written back out)
- a PreToolUse guard for
git reset --hardwent quiet when the repository was in a folder with a Japanese name: thecwdfield was garbled, git could not run there, and the command went through without the guard - a status line script garbled a Japanese model name and path
None of these raised a visible error, because hooks are usually written to fail open. The same scripts work on macOS and Linux, where stdin defaults to UTF-8, so authors testing there will not see it.
Suggested Improvement
In Hook input, one sentence and one example, e.g.:
Hook input is UTF-8 encoded JSON. On Windows, some runtimes default to the system code page for stdin: in Python, readsys.stdin.bufferand decode it as UTF-8 (or run withPYTHONUTF8=1).
Alternatively, Claude Code could set PYTHONUTF8=1 in the environment it gives hook processes, which would make the common json.load(sys.stdin) pattern correct on Windows without changes to existing hooks.
Impact
Medium - Makes feature difficult to understand
Additional Context
Windows 11, Japanese locale (ACP=932), Claude Code 2.1.280, Python 3.11. The fix in my plugins, with tests that send raw UTF-8 the way Claude Code does: https://github.com/tsurutanmen/claude-plugins/pull/5
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
The same applies in the other direction. A SessionStart hook printing
additionalContextthat contains Japanese, measured with Claude Code 2.1.280 on the same machine (Python 3.11, noPYTHONUTF8), asking the model to quote the sentence back:| Hook prints | Model received |
|---|---|
|
print(json.dumps(obj, ensure_ascii=False))| mojibake: 16 × U+FFFD, starting�����t�́u||
print(json.dumps(obj))(non-ASCII as\uescapes) |合言葉は「さくら並木」です||
sys.stdout.reconfigure(encoding="utf-8"), thenensure_ascii=False|合言葉は「さくら並木」です|So on Windows, hook stdout also needs to be UTF-8 (or ASCII-only JSON). A line in the docs covering both directions would cover it, e.g. "Hook input and output are UTF-8. On Windows, Python hooks should read
sys.stdin.bufferand write UTF-8 (sys.stdout.reconfigure(encoding="utf-8")), or run withPYTHONUTF8=1."I checked the public Hookify implementation for the same stdin boundary and reproduced a related failure in its actual hook executors, independently of the rule-file encoding issue.
At main
56f36532530f88b572854538d685fcf781141e8c, all four Hookify entry points calljson.load(sys.stdin). With the same raw UTF-8 JSON and an ASCII-only rule containing a Unicode regex escape, PreToolUse returnspermissionDecision: denyunder UTF-8, but a cp932 text stream reports a decoding error and exits 0; cp1252 and cp932 with surrogateescape silently return{}for that same matching command.This was tested on macOS by setting
PYTHONIOENCODINGon real Python hook subprocesses, not on a native Windows machine. No model request or command from a hook payload was executed.The minimal patch changes the four input reads to:
It leaves ASCII-escaped JSON output and the existing
systemMessage/ exit-0 error handling in place. I have a patch with regression tests covering the four entry points, non-ASCII transcript paths, a non-ASCII PreToolUse command that must be denied, ASCII matches, non-matches, malformed JSON, and invalid UTF-8. The original implementation fails 15 valid UTF-8 regression scenarios; a further 12 invalid-UTF-8 cases fail the desired diagnostic check. All 84 scenarios pass after the patch.I checked the full diffs of #77289 and #79645: those fix rule/transcript file decoding, not stdin. #23967 repairs unescaped backslashes but still reads the locale-decoded text stream. This patch would complement those changes; it does not fix the separate prompt-field issue or claim that native Windows / Claude Code integration has been verified.
For maintainers: PR creation is currently restricted to collaborators. What is the preferred channel for submitting this small public Hookify patch and its tests? I can provide the patch through an accepted channel or work with a collaborator. The broader UTF-8 input/output documentation request here still stands.
This patch and the local checks were prepared with AI assistance.
Measured on Windows 11, Python 3.12.8, system ANSI code page cp1251 — three separate things happen on the way to a hook, and only one of them is fixable inside the hook, which is why the docs sentence matters:
1. Python decodes hook stdin with the ANSI code page, not UTF-8.
So
json.loads(sys.stdin.read())decodes UTF-8 JSON as cp1251 (cp1252, cp932, cp936 elsewhere). Non-ASCII file paths and prompt text arrive as mojibake, and a byte that is undefined in the page raisesUnicodeDecodeErrorinstead.sys.stdin.buffer.read().decode("utf-8")is the fix on the hook side, and it works on every Python version.2. If the hook is invoked through PowerShell 5.1, the text can be destroyed before the hook sees it.
$OutputEncodingdefaults toASCIIEncoding, and the pipeline encodes with it:No hook-side decoding can recover that; it has to be set upstream.
3. The usual upstream fix adds a BOM. Setting
$OutputEncoding = [System.Text.Encoding]::UTF8delivers correct UTF-8 — and a\xef\xbb\xbfpreamble with it, because that .NET encoding emits one:json.loadson that raisesExpecting value: line 1 column 1 (char 0), which reads like malformed JSON rather than an encoding problem — and[System.Text.UTF8Encoding]::new($false)is the variant without the preamble.So the documentation sentence people need is arguably two: the JSON on stdin is UTF-8 and a hook must decode it as UTF-8 explicitly rather than rely on the platform default, with
utf-8-sigif it may be invoked through a PowerShell pipeline. A one-line example in the hooks reference would prevent all three.(I package this handling — UTF-8 with a tolerated BOM on the way in, no BOM on the way out — in https://github.com/AlKor13/winseam, but the three measurements above are the point; the fix is two lines of stdlib either way.)