Hooks
Commands, HTTP endpoints, model prompts and MCP tools that run at points of every agent run, in the format of Claude Code's settings.json hooks.
Overview
Hooks run something of yours at points of a run: before a tool call, after it, when the agent would stop, when a session starts. A hook can refuse a call, change its arguments, add context the model reads, keep the agent working or end the run. They use the hooks format of Claude Code's settings.json, and a hook that matches Claude Code's tool names (Bash, Read, Write, Edit, Glob, Grep, LS, WebFetch, WebSearch, Agent) gets the call in Claude Code's shape, so most hooks a repository has for Claude Code work here as they are. The differences are listed below.
const cog = new Cogitator({
hooks: {
hooks: {
PreToolUse: [
{
matcher: 'Bash',
hooks: [
{
type: 'command',
command: 'node',
args: ['${CLAUDE_PROJECT_DIR}/.claude/hooks/check-command.js'],
},
],
},
],
PostToolUse: [
{ matcher: 'Edit|Write', hooks: [{ type: 'command', command: 'npx prettier --write .' }] },
],
},
},
});Nothing runs until you set hooks: unset, runs behave as before. Under the hood the hooks are run middleware, cogitator:hooks, the outermost layer of every run, so the events below are also hooks your own middleware can implement.
Where Hooks Come From
| Source | Runs |
|---|---|
hooks.hooks | Always: the hooks you give in code |
hooks.settings with the managed files | The organization's managed-settings.json and managed-settings.d/*.json |
hooks.settings with the user files | ~/.cogitator/settings.json, ~/.claude/settings.json |
hooks.settings with the project files | .claude/settings.json, .claude/settings.local.json and the .cogitator/ ones, only once the project is trusted |
settings: true reads the files where the permission rules are read (permissions.settings, else the defaults), settings: { root, userDirs, managedDirs, layers, trusted, trustedRoots } names the places. Hooks run commands, so a project's own files count only for a trusted project: trusted: true trusts the root you name (the working directory of the process without one), trustedRoots lists more project roots, and permissions.settings.trusted: true trusts its own root for hooks too. Trust holds for those roots only: a server whose runs work in many workspaces does not run the hooks of each, and a repository you cloned does not get to run its hooks on your machine. The hooks of an untrusted project are skipped with a warning.
As in Claude Code, hooks of every layer add up, the same handler in several files runs once, disableAllHooks takes the value of the highest layer that sets it (only a managed file turns managed hooks off), and allowManagedHooksOnly in a managed file leaves out the user, project and local hooks. Neither touches the hooks you give in code (hooks.hooks), which are the host's own: leave them out to turn them off. allowedHttpHookUrls and httpHookAllowedEnvVars of a managed file are a ceiling: the lists of the other layers and of hooks can only narrow it. Without a managed list, the lists of every layer together apply. A file is read again once it changes.
Events
A run is a session: run() and resume() start one and end it. A run started from another run's tool call (an agent used as a tool) is a subagent and fires SubagentStart and SubagentStop instead of SessionStart, Stop and SessionEnd.
| Event | When | Matcher filters | What a hook can do |
|---|---|---|---|
SessionStart | A run starts (startup) or goes on from a history or a resume (resume) | the source | Add context after the system prompt (additionalContext, plain stdout) |
UserPromptSubmit | A new run's input, and each queued message | none | Block it (exit 2, decision: "block"), add context after it |
PreToolUse | Before the permissions decide a tool call | the tool | permissionDecision allow, deny, ask, defer, updatedInput, additionalContext |
PermissionRequest | A call asks for approval, before onApproval | the tool | decision.behavior allow (with updatedInput) or deny (with message, interrupt) |
PostToolUse | After a tool call succeeded | the tool | updatedToolOutput, additionalContext, decision: "block" with a reason for the model |
PostToolUseFailure | After a tool call failed | the tool | additionalContext, stderr of exit 2 for the model |
PostToolBatch | Every call of a step resolved, before the next model call | none | additionalContext, or stop the run (decision: "block", exit 2) |
Stop | The agent would stop with its final answer | none | Keep it going (decision: "block" with a reason, exit 2, additionalContext) |
SubagentStart | A delegated run starts | the agent name | Add context to the subagent |
SubagentStop | A delegated run would stop | the agent name | As Stop |
PreCompact | Before a compaction of the turn (context) or the thread (memory.compaction) | auto | Skip it (exit 2, decision: "block") |
PostCompact | After a compaction | auto | Nothing, for logging |
PreModelSwitch | A handoff would move the run to an agent on another model | the model | Block the handoff (exit 2, decision: "block", permissionDecision deny or ask) |
Notification | An approval goes to a person (permission_prompt), a paused run waits on a question (agent_needs_input) | the type | Nothing, for alerts |
SessionEnd | A run ended: completed, paused or failed | other | Nothing, for cleanup |
A matcher is *, empty or absent for every occurrence, names separated by | or , for exact names, and anything else a regular expression tested anywhere in the value. Tool events match the tool's own name, its MCP name (mcp__<server>__<tool>) and Claude Code's name of it, one each as in Claude Code: Bash for run_command and exec, Read for read_file, Write for write_file, Edit for edit_file, Glob, Grep, LS for list_files, WebFetch for http_request and web_scrape, WebSearch for web_search, Agent (and Task) for an agent used as a tool, ExitPlanMode, Skill, TodoWrite. So Read does not match grep and Edit does not match write_file. A hook that matches a call only by Claude Code's name gets tool_name and tool_input in Claude Code's shape (Read with an absolute file_path, Edit with old_string and new_string, Bash with command), and its updatedInput is read back into the tool's own arguments. A hook that matches the tool's own name, or every tool, gets Cogitator's shape. A handler's if holds one permission rule the call must match, Bash(git *) or Edit(src/**), and counts only on tool events.
PreToolUse in the permission flow
PreToolUse runs for each call right before it starts: in a step of several calls after the calls before it ran (or, for calls that run side by side, as each is dispatched), so a hook sees what they changed. It runs before the rules. deny refuses the call and the model reads the reason, ask makes a call ask that the rules would allow, allow skips the prompt only the mode or the tool's own requiresApproval would show (also in dontAsk mode, and in auto mode without asking the classifier), while deny rules, ask rules, plan mode and the asks that guard a deny rule (a command line or a list of files the rules cannot check) still apply, as in Claude Code. defer pauses the run with the call in pendingApprovals. When resume() continues it, PreToolUse runs again for the call, so a hook that deferred can now answer with allow and the updatedInput it collected. updatedInput replaces the arguments: they must pass the tool's schema, and the rules decide them, not the ones the model sent. A call that asks or pauses with them keeps them in the checkpoint (checkpoint.turn.changed): an approval holds for exactly those arguments, and when the hook changes them on resume the call asks again. Streamed tool input (onToolInputAvailable) shows the arguments as the model wrote them. With several hooks deny wins over defer, over ask, over allow.
PermissionRequest answers inside the approval, before onApproval and the guardrails' onToolApproval: a run that would pause, or ask a person, gets the hook's answer instead. Its updatedInput applies with or without a permissions config, checked against the schema, the rules and the guardrails again. Decisions of both are logged with decidedBy: 'hook'.
Handlers
Every handler takes timeout in seconds (600 for command, http and mcp_tool, 30 on UserPromptSubmit and PreModelSwitch and for prompt, 1.5 on SessionEnd), and onFailure: 'block' to make a failure (cannot start, another exit code, a timeout, output that is not valid) do what exit code 2 does on the event. The handlers an event selects run at once.
command
The event's JSON goes to stdin, the answer comes back in the exit code and stdout. With args the command is started directly with them (exec form), ${CLAUDE_PROJECT_DIR} and ${COGITATOR_PROJECT_DIR} replaced by the project root as plain text. Without args the line runs in /bin/sh -c (PowerShell with shell: 'powershell' and on Windows). The process runs in the project root with the runtime's environment (without the OTEL_* variables), as Claude Code passes it, and CLAUDE_PROJECT_DIR and COGITATOR_PROJECT_DIR set. On a server whose environment holds secrets, set hooks.env: { allow: ['PATH', 'HOME'] } to pass only the variables you name, or run the server with a minimal environment. The process gets a group of its own that is killed whole at the timeout or when the run is aborted, and also when a process it left in the background still holds its output a moment after it exited (redirect the output of a background process, as Claude Code asks): the run is then logged as an error and its output is not used. The tool call's arguments reach it only on stdin, never on the command line. stdout and stderr are read up to maxOutputBytes each (1 MiB). async: true runs it in the background: its additionalContext reaches the next model call, its decisions have no effect, and it is killed when the run ends.
| Exit code | Effect |
|---|---|
| 0 | Success. JSON on stdout (one object, nothing else) applies, plain text is context on SessionStart and UserPromptSubmit |
| 2 | Blocks where the event can block, stderr is the reason (the model reads it on PreToolUse, Stop, PostToolUse) |
| other | A failure that lets the action go on. Valid JSON on stdout still decides, as Claude Code documents, and the run is logged as an error |
http
The event's JSON is posted to url. The request goes only there: redirects are not followed, and with allowedHttpHookUrls set (in hooks, or a settings file) a URL that matches none of its patterns (* for any text) is skipped. headers values take $VAR and ${VAR} only from the environment variables of allowedEnvVars (narrowed by httpHookAllowedEnvVars), others become empty. A 2xx answer with a JSON object body is read like a command's stdout, an empty body is a success with no output, anything else is a failure that lets the action go on.
prompt
A question to a model: prompt with $ARGUMENTS replaced by the event's JSON (appended when it has none), sent to model, else hooks.promptModel, else llm.defaultModel. The model answers { "ok": true } or { "ok": false, "reason": "..." }, checked against that schema. ok: false denies a PreToolUse call and ends the run (continueOnBlock: true only denies it), keeps a Stop going with the reason (unless the model says "impossible": true), blocks UserPromptSubmit and stops at PostToolBatch. Its cost counts in the run's usage.
mcp_tool
Calls tool on the MCP server server through hooks.mcp ({ callTool(server, tool, input, signal) }), the string values of input with ${tool_input.path} and the like replaced from the event's JSON. The tool's text is read like a command's stdout. Without hooks.mcp the hook is skipped with a warning.
Input and Output
Every input carries session_id (the thread), run_id, cwd (the workspace directory the run works in), hook_event_name, agent_name, permission_mode (default for a run without permissions), and agent_id and agent_type in a subagent, then the event's own fields as Claude Code names them: tool_name, tool_input, tool_use_id, tool_response, error, prompt, source, last_assistant_message, stop_hook_active, tool_calls, trigger, from_model, to_model, notification_type, reason. tool_name and tool_input are Cogitator's, or Claude Code's for a hook that matched Claude Code's name (see Events). stop_hook_active is true from the first time a Stop hook kept the run going until the next user message, tool calls in between included. UserPromptSubmit does not run for a subagent, SubagentStart covers it.
The JSON output fields are Claude Code's: continue: false (with stopReason) ends the run before its next model call (also one a middleware is about to send, and the tool calls of an answer it came with are not run), the run completes with result.stopped. systemMessage is for a person: it goes to the log and onHookRun. decision, reason and hookSpecificOutput decide per event as the table above says. Context a hook adds goes to the model as a system message after the step's tool results, saved to the thread, SessionStart context only for its run. Each text is cut at 10 000 characters.
Logs
Every handler run is saved to the thread as a custom entry, { kind: 'hook', event, type, target, source, outcome, exitCode, durationMs, decision, reason, systemMessage }, and reported to hooks.onHookRun:
const cog = new Cogitator({
hooks: {
settings: { root: '/srv/repo', trusted: true },
onHookRun: (run) => {
if (run.systemMessage) notifyUser(run.systemMessage);
if (run.outcome === 'error' || run.outcome === 'timeout') console.warn(run);
},
},
});Differences From Claude Code
agenthooks and the events Cogitator has no point for (Setup,InstructionsLoaded,PermissionDenied,FileChanged,CwdChanged,ConfigChange,Worktree*,Elicitation*,PostModelSwitchand the rest) are skipped with a warning.asyncRewakeandonceare ignored with a warning,updatedPermissionstoo.- There is no transcript file: inputs carry no
transcript_path, andlast_assistant_messagegives the answer. - Only the built-in tools listed under Events have Claude Code's names and shapes. Tools of your own and MCP tools are matched and sent as they are,
tool_responseis always Cogitator's result. deferworks for any number of calls of a step, each waits inpendingApprovals.- A
prompthook onPermissionRequestthat answersok: falsedeclines the approval (in Claude Code it has no effect), over any other hook that allows it. PreModelSwitchfires for handoffs, as a run changes its model only there, and blocking it keeps the run on its agent.- Cogitator has no trust dialog:
trustedandtrustedRootsdecide it, for the roots they name, and it is off by default for hooks (Claude Code's-pmode trusts every folder). - In a YAML config (
@cogitator-ai/config) write$${CLAUDE_PROJECT_DIR}:${...}there reads the environment.
Your Own Events
The hooks are middleware, so the same points are open to code. See Middleware for startRun, beforeToolCall, decideApproval, afterToolBatch, beforeStop, beforeCompaction, beforeModelSwitch and onNotification, and hooksMiddleware(config) to give one agent its own hooks.
Run Middleware
Hooks around every model and tool call of a run, with tripwire guards that stop it. PII masking, guardrails and compaction are built on them.
Subagents
Agents a run hands tasks to, defined as Markdown files in Claude Code's format, with approvals that bubble up, background runs and git worktree isolation.