From codex-hooks
Load this skill immediately after a user mentions "@goodfoot/codex-hooks" or Codex hooks.
How this skill is triggered — by the user, by Claude, or both
Slash command
/codex-hooks:sdkThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
**Review the authoritative documentation at `https://developers.openai.com/codex/hooks.md` and the wire schemas under `third_party/reference/codex/codex-rs/hooks/schema/generated/` before using `@goodfoot/codex-hooks`. When the doc and the schemas disagree, the schemas win.**
Review the authoritative documentation at https://developers.openai.com/codex/hooks.md and the wire schemas under third_party/reference/codex/codex-rs/hooks/schema/generated/ before using @goodfoot/codex-hooks. When the doc and the schemas disagree, the schemas win.
Hooks are compiled executables, not scripts. You must build them before Codex can see them.
The Build Command:
npx -y @goodfoot/codex-hooks -i "src/**/*.ts" -o ".codex/hooks.json"
Parameters Explained:
-i "src/**/*.ts": Input Glob. Tells the compiler where your TypeScript source files are.
"...") to prevent your shell from expanding it before the CLI sees it.-o ".codex/hooks.json": Output Manifest. This is the file Codex loads at startup.
bin/ folder next to this file containing the compiled .mjs executables.--executable <path> (Optional): Executable Prefix. Sets the executable used in generated commands (default: node).--loader .ext=type (Optional, repeatable): Explicit Asset Loader. Registers esbuild loaders for non-code imports. .md=text is enabled by default; opt in for other extensions, e.g. --loader .txt=text.Configuring the log file: Logging is configured at runtime, not at build time. Set the CODEX_HOOKS_LOG_FILE environment variable to write JSON-line logs to a file, or configure programmatically with new Logger({ logFilePath }) / new Logger({ logEnvVar }). See Logging & Debugging.
Loader guidance:
SessionStart and SubagentStart preambles:
import preamble from './prompts/session-start.md';
import { sessionStartHook, sessionStartOutput } from '@goodfoot/codex-hooks';
export default sessionStartHook({}, () => {
return sessionStartOutput({
additionalContext: preamble
});
});
codex-hooks build passes.Here is a complete, working example of a PreToolUse hook. It uses the Factory Pattern (preToolUseHook) and the Output Builder (preToolUseOutput).
Goal: Prevent accidental deletion of the root directory.
// src/block-dangerous.ts
import { preToolUseHook, preToolUseOutput } from '@goodfoot/codex-hooks';
// 1. Export Default is MANDATORY.
// 2. Factory handles input typing and error wrapping.
// 3. tool_name is matched as a string (Codex does not narrow per-tool).
export default preToolUseHook({ matcher: 'shell' }, (input, { logger }) => {
// 4. Input uses wire format (snake_case: tool_input, tool_name).
// 5. tool_input is `unknown` — narrow it with a user-defined type guard.
const command = isShellInput(input.tool_input) ? input.tool_input.command : '';
// 6. Logging uses the context logger, NEVER console.log or console.error.
logger.info('Checking command safety', { command });
if (command.includes('rm -rf /')) {
logger.warn('Blocked dangerous root deletion', { command });
// 7. Return structured output using the builder.
// 8. systemMessage is shown to the user in the UI.
return preToolUseOutput({
systemMessage: 'Safety: Dangerous root deletion command blocked.',
permissionDecision: 'deny',
permissionDecisionReason: 'Safety Policy: Root deletion is forbidden.'
});
}
// 9. Default: Allow execution with a status message.
return preToolUseOutput({
systemMessage: 'Command validated by safety policy.'
});
});
function isShellInput(value: unknown): value is { command: string } {
return typeof value === 'object' && value !== null && typeof (value as { command?: unknown }).command === 'string';
}
tool_inputCodex passes tool_input as unknown because tool schemas are tool-defined and may evolve independently of the hook runtime. Use user-defined type guards to narrow:
function isShellInput(value: unknown): value is { command: string; cwd?: string } {
if (typeof value !== 'object' || value === null) return false;
const candidate = value as { command?: unknown };
return typeof candidate.command === 'string';
}
export default preToolUseHook({ matcher: 'shell' }, (input) => {
if (!isShellInput(input.tool_input)) return preToolUseOutput({});
// input.tool_input is now { command: string; cwd?: string }
return preToolUseOutput({});
});
The package does not ship per-tool predicates or content helpers — Codex tools are defined by the host, not by the hooks SDK. Define narrowing helpers locally in your hook code or in a shared module.
Use the scaffold command when setting up new packages. This generates a complete TypeScript project with tests, linting, and build scripts.
Scaffold Command:
npx @goodfoot/codex-hooks --scaffold ./my-codex-hooks --hooks SessionStart,PreToolUse -o ./.codex/hooks.json
What you get:
src/: Type-safe hook implementations.test/: Vitest tests for your hooks.package.json: Configured with build, test, and lint scripts.tsconfig.json & biome.json: Best-practice configuration.Next Steps:
cd my-codex-hooksnpm installnpm run build (Compiles hooks to the specified output path)npm test (Runs the generated tests)Available Hook Types: PreToolUse, PostToolUse, PermissionRequest, UserPromptSubmit, SessionStart, SubagentStart, Stop, SubagentStop, PreCompact, PostCompact
Monorepo? Use -o to output directly to a plugin directory:
npx @goodfoot/codex-hooks --scaffold ./packages/my-codex-hooks --hooks PreToolUse,PostToolUse -o ../../plugins/my-plugin/.codex/hooks.json
See Installation: Scaffolding for Monorepos.
Different hooks have different capabilities. This table is built from the Codex output schemas (codex-rs/hooks/schema/generated/*.output.schema.json).
| Hook Type | Can Block? | Can Deny? | Can Add Context? | Hook-Specific Output |
|---|---|---|---|---|
| PreToolUse | Yes (decision: 'block', legacy) | Yes (permissionDecision: 'deny') | Yes (additionalContext) | { permissionDecision: 'allow'|'deny'|'ask', permissionDecisionReason, additionalContext, updatedInput } |
| PostToolUse | Yes (decision: 'block') | No | Yes (additionalContext) | { additionalContext, updatedMCPToolOutput } |
| PermissionRequest | No | Yes (decision.behavior: 'deny') | No | { decision: { behavior: 'allow'|'deny', message?, interrupt?, updatedInput?, updatedPermissions? } } |
| UserPromptSubmit | Yes (decision: 'block') | No | Yes (additionalContext) | { additionalContext } |
| SessionStart | No | No | Yes (additionalContext) | { additionalContext } |
| SubagentStart | No | No | Yes (additionalContext) | { additionalContext } |
| Stop | Yes (decision: 'block' requires reason) | N/A | No | none |
| SubagentStop | Yes (decision: 'block' requires reason) | N/A | No | none |
| PreCompact | No | No | No | none (universal only) |
| PostCompact | No | No | No | none (universal only) |
Key distinctions vs. Claude Code:
PreToolUse.permissionDecision accepts allow, deny, and ask (reserved — fail-closed in some Codex builds; see Section 5).PermissionRequest uses a nested decision: { behavior, message? } object, not permissionDecision.Stop / SubagentStop use decision: 'block' with a required reason.PreCompact and PostCompact accept only the universal envelope fields (continue, stopReason, suppressOutput, systemMessage) — no hookSpecificOutput.Universal envelope fields (every hook): continue (default true), stopReason, suppressOutput, systemMessage.
These constraints are unique to Codex (not present in Claude Code):
async hook entries in the configuration, but the runtime does not yet execute them.permissionDecision: 'ask': reserved. The wire schema permits it, but some Codex versions treat it as fail-closed. Treat ask as best-effort and verify against the target Codex build's output_parser.rs unsupported_* helpers.updatedInput (PreToolUse): only honored when permissionDecision: 'allow'. Emitting it with deny or ask is a no-op (and may fail-closed in stricter builds).interrupt: true, updatedInput, updatedPermissions: reserved (fail-closed). Emit them only if you have confirmed the target Codex build supports them; otherwise omit.import { permissionRequestHook, permissionRequestOutput } from '@goodfoot/codex-hooks';
export default permissionRequestHook({ matcher: 'shell' }, (input, { logger }) => {
const cmd = isShellInput(input.tool_input) ? input.tool_input.command : '';
if (cmd.startsWith('echo ')) {
logger.info('Auto-allowing echo', { cmd });
return permissionRequestOutput({
behavior: 'allow'
});
}
return permissionRequestOutput({ behavior: 'deny' });
});
function isShellInput(value: unknown): value is { command: string } {
return typeof value === 'object' && value !== null && typeof (value as { command?: unknown }).command === 'string';
}
import { stopHook, stopOutput } from '@goodfoot/codex-hooks';
export default stopHook({}, (_input, { logger }) => {
const ready = false;
if (!ready) {
logger.info('Blocking stop');
return stopOutput({
decision: 'block',
reason: 'Pending operations must complete first.',
systemMessage: 'Stop blocked: pending operations in progress.'
});
}
return stopOutput({});
});
import { sessionStartHook, sessionStartOutput } from '@goodfoot/codex-hooks';
export default sessionStartHook({ matcher: 'startup' }, () => {
return sessionStartOutput({
additionalContext: 'Project conventions: TypeScript strict, no `any`.'
});
});
When helping a user with hooks, you MUST follow this protocol:
@goodfoot/codex-hooks.npx ... (or npm run build if scaffolded) after every edit..md, .txt, or similar assets, ensure codex-hooks --loader ... and the test runner configuration agree.console.log & console.error: Aggressively correct any code using console.log or console.error to use logger. Stdio is reserved for the protocol; direct writes cause silent failures or UI corruption.export default hookFactory(...).ask, reserved fields).Before debugging hook issues, verify:
@goodfoot/codex-hooks is in package.json dependenciespackage.json (e.g., "build": "codex-hooks -i ...")npm run build)console.log or console.error in hook code (use logger instead)export default hookFactory(...) patternThe CLI picks one of three command-emission modes based on the output path and flags:
| Mode | Trigger | Command form | Filename |
|---|---|---|---|
| plugin | --plugin-root, or a .codex-plugin/ marker found by walking up from the output path | node "${PLUGIN_ROOT}/hooks/<name>.mjs" | stable (no hash) |
| codex-local | Output path contains a .codex/ segment | node "$(git rev-parse --show-toplevel)/.codex/bin/<name>.<hash>.mjs" | hashed |
| absolute | Anything else | node "/abs/path/to/<name>.<hash>.mjs" | hashed |
Standalone Project (codex-local):
Place the compiled manifest at .codex/hooks.json in your project root. Codex auto-discovers it.
npx -y @goodfoot/codex-hooks -i "src/**/*.ts" -o ".codex/hooks.json"
Codex Plugin (Recommended):
Build into the plugin's hooks/ directory and pass --plugin-root (or place a .codex-plugin/ marker so it auto-detects):
npx -y @goodfoot/codex-hooks -i "src/**/*.ts" -o "./hooks/hooks.json" --plugin-root
Plugin mode emits ${PLUGIN_ROOT}-relative commands and stable, hash-free filenames so the built hooks.json is portable inside an installed plugin and Codex's hook trust hash stays valid across rebuilds. Codex injects PLUGIN_ROOT (and CLAUDE_PLUGIN_ROOT for compatibility) into plugin hook environments and substitutes ${PLUGIN_ROOT} before execution.
Monorepo Project:
Output to a sibling plugin directory and let --plugin-root anchor the command form:
npx -y @goodfoot/codex-hooks -i "src/**/*.ts" -o "../../plugins/my-plugin/hooks/hooks.json" --plugin-root
See Monorepo Integration.
Filename overrides: --stable-names forces hash-free names in any mode; --no-stable-names opts back into hashed names (the pre-1.1 default).
tool_input narrowing.Creates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.
npx claudepluginhub goodfoot-io/marketplace --plugin codex-hooks