Skip to main content

HookRunner

port.HookRunner handles lifecycle events around prompts, tool calls, agent teams, and run termination. Implement it to apply dynamic policy, redact tool results, record audit data, or notify another system.

The interface

type HookRunner interface {
Run(
ctx context.Context,
event governance.HookEvent,
) (governance.HookOutcome, error)
}

Mecatl calls Run synchronously for every configured phase. A nil HookRunner disables hooks.

type HookEvent struct {
Phase HookPhase
Tool string
Input json.RawMessage
SessionID string
CallID string
}

type HookOutcome struct {
Block bool
Message string
Mutated json.RawMessage
AskApproval bool
}

Tool and CallID are set for tool-use phases. Input has a shape defined by the phase.

Block stops phases that support a veto. Message explains the outcome. Mutated replaces a supported input or result when it contains valid JSON in the required shape.

AskApproval changes a PreToolUse block into an interactive permission request. It has no effect on other phases or on headless runs. A runner can also implement port.HookApprovalLearner to remember an "allow always" verdict.

Hook phases

PhaseWhen it runsInputEffect of BlockMutation
SessionStartBefore the first promptEmptyAborts the runNone
UserPromptSubmitAfter command expansion, before recording the promptPrompt objectRejects the promptReplaces the prompt
PreToolUseAfter permission approval, before executionTool argumentsPrevents executionReplaces tool arguments
PostToolUseAfter executionArguments and resultAdds an annotationReplaces the result
StopWhen the main loop terminatesStop reasonNo effectNone
SubagentStopWhen a subagent stopsStop reasonNo effectNone
TeammateIdleWhen a team member becomes idleTeam-member dataNo effectNone
TaskCreatedBefore a team task is createdTask dataPrevents creationNone
TaskCompletedBefore a team task is completedTask dataPrevents completionNone

Stop, SubagentStop, and TeammateIdle are best-effort notifications. Mecatl ignores block outcomes. If their caller context is already canceled, Mecatl uses a detached, five-second context so the notification can still run.

Return an outcome

Return HookOutcome{} to allow the action unchanged.

For a veto, set Block: true. Errors from SessionStart, UserPromptSubmit, and PreToolUse also fail closed. A PreToolUse block produces a model-visible tool error so the model can choose another action.

To change a supported payload, return valid JSON in Mutated:

  • UserPromptSubmit: {"prompt":"..."}
  • PreToolUse: replacement tool arguments
  • PostToolUse: {"content":"...","is_error":true}

The permission policy is not run again after a trusted hook changes PreToolUse arguments. Treat hook implementations as trusted code.

Mecatl ignores malformed mutation JSON and emits a client-visible informational hook event.

Redact a tool result

A PostToolUse block cannot undo a tool that has already run or hide its result. It adds an annotation to the client event stream.

Use a mutation to replace unsafe output:

{
"content": "blocked by guardrail: unsafe content detected",
"is_error": true
}

Mecatl records, emits, and sends the rewritten result to the model. The original tool output does not enter those paths.

Use the shell hook runner

internal/adapter/hookexec maps phases to shell commands in the shipped applications:

hooks := hookexec.New(map[governance.HookPhase]string{
governance.PhasePreToolUse: "/usr/local/bin/check-call",
governance.PhasePostToolUse: "/usr/local/bin/redact-result",
governance.PhaseSessionStart: "/usr/local/bin/audit-session",
})

The runner writes HookEvent as JSON to the command's standard input. It uses /bin/sh and a 30-second timeout by default. On POSIX systems, cancellation or timeout terminates the hook's process group, including child processes.

Exit codeOutcome
0Allow
2Block
Other nonzero valueHook error

For exit code 0, plain standard output becomes Message. Output that begins with { can return a control envelope:

{
"mutated": {
"content": "redacted",
"is_error": false
},
"message": "Removed a credential from the result."
}

For exit code 2, standard output supplies the block message, with standard error as a fallback. hookexec.WithTimeout and hookexec.WithShell override the defaults.

Implement a runner

Keep synchronous hooks fast because they add latency to the run. Use the caller's context and return promptly after cancellation.

type AuditHooks struct {
sink AuditSink
}

func (h AuditHooks) Run(
ctx context.Context,
event governance.HookEvent,
) (governance.HookOutcome, error) {
if err := h.sink.Record(ctx, event); err != nil {
return governance.HookOutcome{}, err
}
return governance.HookOutcome{}, nil
}

Mecatl fails closed on errors from SessionStart, UserPromptSubmit, and PreToolUse. Errors from TaskCreated and TaskCompleted fail open so a broken hook cannot stop team coordination. PostToolUse errors leave the tool result unchanged, and notification errors have no effect on the run.

The shipped guardrail checker also uses this interface. It blocks unsafe outbound calls in PreToolUse and replaces unsafe inbound results in PostToolUse.

Next steps