lets hooks and lets hook classify
How lets reaches an agent without asking it to remember a prose rule. Two commands: lets hooks install|uninstall <claude-code|codex> wires the integration into the agent’s own settings;
lets hook classify is the program those hooks call on every shell command.
lets hooks install <claude-code|codex>
lets hooks uninstall <claude-code|codex>
lets hook classify
What hooks install does
claude-code merges three hooks into ~/.claude/settings.json:
- A
SessionStarthook (matcherstartup|resume|clear|compact|fork) that prints a short paragraph explainingletsand its verbs at the start of every session — including after compaction, since injected context does not survive compaction the way a system prompt does. - A
SubagentStarthook that delivers the same paragraph asadditionalContext, since--append-system-promptdoes not reach a non-fork subagent. - A
PreToolUsehook onBash, matcher covering every shell call, that pipes the command tolets hook classifyand blocks only a bare read it can classify with confidence and that has a runnableletsreplacement — acat,sed -norgrepof a repo file whose stdout goes to the tool result, not into a pipe or a subshell, plus a baresed -i 's/OLD/NEW/g'substitution against an in-tree file, replaced withlets edit <path> --old '<OLD>' --new '<NEW>' --all.
codex merges a PreToolUse entry into hooks.json (default $CODEX_HOME/hooks.json, or
~/.codex/hooks.json) and prints one paragraph to add to ~/.codex/AGENTS.md by hand — Codex has
no session-start hook to inject a paragraph automatically the way Claude Code does.
Both installers only ever write into the agent’s own settings; nothing modifies your shell
profile. Installing twice is a no-op — the settings file is byte-identical across a reinstall.
hooks uninstall removes exactly what hooks install added and leaves any of the user’s own
hooks in the same file untouched.
What lets hook classify does
Reads one PreToolUse JSON event (session_id, cwd, hook_event_name, tool_name,
tool_input.command) on stdin. For an allowed command it prints nothing and exits 0 — the
command runs unmodified. For a blocked command it prints one JSON line on stdout naming the
lets replacement, in the shape Claude Code’s PreToolUse hook contract expects
(hookSpecificOutput.permissionDecision: "deny").
The classifier parses the command with a real bash grammar, walks pipelines, &&/; lists and
substitutions, and blocks only what it can translate with confidence. It fails open: lets
missing, crashing, or mid-update degrades every hook to allow, never an error that could wedge an
agent’s turn.
What passes unblocked, deliberately: output piped into another program (cat f | jq, cat f | wc), a command substitution or process substitution ($(cat f), <(cat f)), a heredoc sent to
another program’s stdin, and any path outside the working tree.
A sed -i substitution is blocked only in its narrowest form: a bare sed -i 's/OLD/NEW/g'
(the g flag is required), every target path in-tree, non-glob, resolvable, and neither a
directory nor duplicated, and a substitution that is literal — no regex metacharacters, no
line-range prefix. Anything looser (a different -i suffix, a non-global substitution, a regex
with .* or &) passes through unblocked, since a wrong translation is worse than none.
Flags
Both hooks install/hooks uninstall and hook classify take only the shared flags
(--json, --jsonl, --budget, --max-bytes, --max-file-bytes, --no-ignore,
--allow-outside, --no-check, -q/--quiet); hook classify takes its event as stdin, not as a
flag.
Exit codes
| Exit | Slug | Means |
|---|---|---|
| 0 | — | installed, uninstalled, or nothing to remove |
| 1 | path_conflict |
a different lets comes first on PATH; named in the message, nothing written |
| 1 | not_on_path |
no lets reachable on PATH at all, so a hook could not run it |
| 7 | io_error |
the settings file could not be read or written |
Examples
Installing for Claude Code adds all three hooks in one call:
$ lets hooks install claude-code
added the PreToolUse hook
added the SubagentStart hook
added the SessionStart hook
A second install is a no-op, reported as such, and the settings file does not change:
$ lets hooks install claude-code
the PreToolUse hook was already installed
the SubagentStart hook was already installed
the SessionStart hook was already installed
Installing when a different lets shadows this one on PATH refuses and names it:
$ lets hooks install claude-code
? 1
a different `lets` comes first on PATH at [CWD]/fake-bin/lets — this one is [..]/lets · remove the other, or put this one's directory ahead of it on PATH
ERROR_CODE=path_conflict
lets hook classify blocking a bare cat, then the replacement it named:
$ printf '{"session_id":"s","cwd":"%s","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"cat src/usage.ts"}}' "$(pwd)" | lets hook classify
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"lets show reads several files and ranges in one call.\nrun: lets show src/usage.ts"}}
$ lets show src/usage.ts
── src/usage.ts (1-9 of 9) · sha:9925e474e391
1 const cap = 10
...
── showed 1 target · 9 lines
lets hook classify letting a piped cat through untouched — its stdout never reaches the tool
result, so there is nothing to replace:
$ printf '{"session_id":"s","cwd":"%s","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"cat config/app.json | wc -l"}}' "$(pwd)" | lets hook classify
(stdout is empty, exit code 0 — the command runs unmodified.)
Installing for Codex adds the PreToolUse hook and prints the one line to add by hand:
$ lets hooks install codex
added the PreToolUse hook
For reading, finding and editing files, use `lets` (run `lets guide` once) instead of `cat`,
`grep` or `sed -n`. It reads several files or ranges in one call, returns bounded numbered
output, and its edits return the changed region — so do not follow a `lets` call with a `cat` or
`sed -n` to check the result.
add this to ~/.codex/AGENTS.md by hand
Back to the top of lets hooks and lets hook classify