Cogitator
Core

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

SourceRuns
hooks.hooksAlways: the hooks you give in code
hooks.settings with the managed filesThe 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.

EventWhenMatcher filtersWhat a hook can do
SessionStartA run starts (startup) or goes on from a history or a resume (resume)the sourceAdd context after the system prompt (additionalContext, plain stdout)
UserPromptSubmitA new run's input, and each queued messagenoneBlock it (exit 2, decision: "block"), add context after it
PreToolUseBefore the permissions decide a tool callthe toolpermissionDecision allow, deny, ask, defer, updatedInput, additionalContext
PermissionRequestA call asks for approval, before onApprovalthe tooldecision.behavior allow (with updatedInput) or deny (with message, interrupt)
PostToolUseAfter a tool call succeededthe toolupdatedToolOutput, additionalContext, decision: "block" with a reason for the model
PostToolUseFailureAfter a tool call failedthe tooladditionalContext, stderr of exit 2 for the model
PostToolBatchEvery call of a step resolved, before the next model callnoneadditionalContext, or stop the run (decision: "block", exit 2)
StopThe agent would stop with its final answernoneKeep it going (decision: "block" with a reason, exit 2, additionalContext)
SubagentStartA delegated run startsthe agent nameAdd context to the subagent
SubagentStopA delegated run would stopthe agent nameAs Stop
PreCompactBefore a compaction of the turn (context) or the thread (memory.compaction)autoSkip it (exit 2, decision: "block")
PostCompactAfter a compactionautoNothing, for logging
PreModelSwitchA handoff would move the run to an agent on another modelthe modelBlock the handoff (exit 2, decision: "block", permissionDecision deny or ask)
NotificationAn approval goes to a person (permission_prompt), a paused run waits on a question (agent_needs_input)the typeNothing, for alerts
SessionEndA run ended: completed, paused or failedotherNothing, 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 codeEffect
0Success. JSON on stdout (one object, nothing else) applies, plain text is context on SessionStart and UserPromptSubmit
2Blocks where the event can block, stderr is the reason (the model reads it on PreToolUse, Stop, PostToolUse)
otherA 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

  • agent hooks and the events Cogitator has no point for (Setup, InstructionsLoaded, PermissionDenied, FileChanged, CwdChanged, ConfigChange, Worktree*, Elicitation*, PostModelSwitch and the rest) are skipped with a warning. asyncRewake and once are ignored with a warning, updatedPermissions too.
  • There is no transcript file: inputs carry no transcript_path, and last_assistant_message gives 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_response is always Cogitator's result.
  • defer works for any number of calls of a step, each waits in pendingApprovals.
  • A prompt hook on PermissionRequest that answers ok: false declines the approval (in Claude Code it has no effect), over any other hook that allows it.
  • PreModelSwitch fires for handoffs, as a run changes its model only there, and blocking it keeps the run on its agent.
  • Cogitator has no trust dialog: trusted and trustedRoots decide it, for the roots they name, and it is off by default for hooks (Claude Code's -p mode 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.

On this page