diff --git a/agent-schema.json b/agent-schema.json
index cd14a43fe..6a0f9c558 100644
--- a/agent-schema.json
+++ b/agent-schema.json
@@ -411,6 +411,20 @@
"items": {
"$ref": "#/definitions/HookDefinition"
}
+ },
+ "stop": {
+ "type": "array",
+ "description": "Hooks that run when the model finishes responding and is about to hand control back to the user. Can perform post-response validation or logging.",
+ "items": {
+ "$ref": "#/definitions/HookDefinition"
+ }
+ },
+ "notification": {
+ "type": "array",
+ "description": "Hooks that run when the agent sends a notification (error, warning) to the user. Can send external notifications or log events.",
+ "items": {
+ "$ref": "#/definitions/HookDefinition"
+ }
}
},
"additionalProperties": false
@@ -1559,4 +1573,4 @@
"additionalProperties": false
}
}
-}
\ No newline at end of file
+}
diff --git a/docs/configuration/agents/index.md b/docs/configuration/agents/index.md
index 6475a3229..63c31506a 100644
--- a/docs/configuration/agents/index.md
+++ b/docs/configuration/agents/index.md
@@ -42,6 +42,8 @@ agents:
session_start: [list]
session_end: [list]
on_user_input: [list]
+ stop: [list]
+ notification: [list]
structured_output: # Optional: constrain output format
name: string
schema: object
diff --git a/docs/configuration/hooks/index.md b/docs/configuration/hooks/index.md
index d7669727e..341c2a11f 100644
--- a/docs/configuration/hooks/index.md
+++ b/docs/configuration/hooks/index.md
@@ -21,20 +21,24 @@ Hooks allow you to execute shell commands or scripts at key points in an agent's
- Block dangerous operations based on custom rules
- Set up the environment when a session starts
- Clean up resources when a session ends
+- Log or validate model responses before returning to the user
+- Send external notifications on agent errors or warnings
## Hook Types
-There are five hook event types:
+There are seven hook event types:
-| Event | When it fires | Can block? |
-| --------------- | ---------------------------------------- | ---------- |
-| `pre_tool_use` | Before a tool call executes | Yes |
-| `post_tool_use` | After a tool completes successfully | No |
-| `session_start` | When a session begins or resumes | No |
-| `session_end` | When a session terminates | No |
-| `on_user_input` | When the agent is waiting for user input | No |
+| Event | When it fires | Can block? |
+| ---------------- | ------------------------------------------------------ | ---------- |
+| `pre_tool_use` | Before a tool call executes | Yes |
+| `post_tool_use` | After a tool completes successfully | No |
+| `session_start` | When a session begins or resumes | No |
+| `session_end` | When a session terminates | No |
+| `on_user_input` | When the agent is waiting for user input | No |
+| `stop` | When the model finishes responding | No |
+| `notification` | When the agent emits a notification (error or warning) | No |
## Configuration
@@ -74,6 +78,16 @@ agents:
on_user_input:
- type: command
command: "./scripts/notify.sh"
+
+ # Run when the model finishes responding
+ stop:
+ - type: command
+ command: "./scripts/log-response.sh"
+
+ # Run on agent errors and warnings
+ notification:
+ - type: command
+ command: "./scripts/alert.sh"
```
## Matcher Patterns
@@ -107,22 +121,29 @@ Hooks receive JSON input via stdin with context about the event:
### Input Fields by Event Type
-| Field | pre_tool_use | post_tool_use | session_start | session_end | on_user_input |
-| ----------------- | ------------ | ------------- | ------------- | ----------- | ------------- |
-| `session_id` | ✓ | ✓ | ✓ | ✓ | ✓ |
-| `cwd` | ✓ | ✓ | ✓ | ✓ | ✓ |
-| `hook_event_name` | ✓ | ✓ | ✓ | ✓ | ✓ |
-| `tool_name` | ✓ | ✓ | | | |
-| `tool_use_id` | ✓ | ✓ | | | |
-| `tool_input` | ✓ | ✓ | | | |
-| `tool_response` | | ✓ | | | |
-| `source` | | | ✓ | | |
-| `reason` | | | | ✓ | |
+| Field | pre_tool_use | post_tool_use | session_start | session_end | on_user_input | stop | notification |
+| ---------------------- | ------------ | ------------- | ------------- | ----------- | ------------- | ---- | ------------ |
+| `session_id` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
+| `cwd` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
+| `hook_event_name` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
+| `tool_name` | ✓ | ✓ | | | | | |
+| `tool_use_id` | ✓ | ✓ | | | | | |
+| `tool_input` | ✓ | ✓ | | | | | |
+| `tool_response` | | ✓ | | | | | |
+| `source` | | | ✓ | | | | |
+| `reason` | | | | ✓ | | | |
+| `stop_response` | | | | | | ✓ | |
+| `notification_level` | | | | | | | ✓ |
+| `notification_message` | | | | | | | ✓ |
The `source` field for `session_start` can be: `startup`, `resume`, `clear`, or `compact`.
The `reason` field for `session_end` can be: `clear`, `logout`, `prompt_input_exit`, or `other`.
+The `stop_response` field contains the model's final text response.
+
+The `notification_level` field can be: `error` or `warning`.
+
## Hook Output
Hooks communicate back via JSON output to stdout:
@@ -165,6 +186,10 @@ The `hook_specific_output` for `pre_tool_use` supports:
| `permission_decision_reason` | string | Explanation for the decision |
| `updated_input` | object | Modified tool input (replaces original) |
+### Plain Text Output
+
+For `session_start`, `post_tool_use`, and `stop` hooks, plain text written to stdout (i.e., output that is not valid JSON) is captured as additional context for the agent.
+
## Exit Codes
Hook exit codes have special meaning:
@@ -175,7 +200,37 @@ Hook exit codes have special meaning:
| `2` | Blocking error — stop the operation |
| Other | Error — logged but execution continues |
-## Example: Validation Script
+## Timeout
+
+Hooks have a default timeout of 60 seconds. You can customize this per hook:
+
+```yaml
+hooks:
+ pre_tool_use:
+ - matcher: "*"
+ hooks:
+ - type: command
+ command: "./slow-validation.sh"
+ timeout: 120 # 2 minutes
+```
+
+
+
⚠️ Performance
+
+
Hooks run synchronously and can slow down agent execution. Keep hook scripts fast and efficient. Consider using suppress_output: true for logging hooks to reduce noise.
+
+
+
+
+
ℹ️ Session End and Cancellation
+
+
session_end hooks are designed to run even when the session is interrupted (e.g., Ctrl+C). They are still subject to their configured timeout.
+
+
+
+## Examples
+
+### Validation Script
A simple pre-tool-use hook that blocks dangerous shell commands:
@@ -201,7 +256,7 @@ echo '{"decision": "allow"}'
exit 0
```
-## Example: Audit Logging
+### Audit Logging
A post-tool-use hook that logs all tool calls:
@@ -222,24 +277,74 @@ echo '{"continue": true}'
exit 0
```
-## Timeout
+### Session Lifecycle
-Hooks have a default timeout of 60 seconds. You can customize this per hook:
+Session start and end hooks for environment setup and cleanup:
```yaml
hooks:
- pre_tool_use:
- - matcher: "*"
- hooks:
- - type: command
- command: "./slow-validation.sh"
- timeout: 120 # 2 minutes
+ session_start:
+ - type: command
+ timeout: 10
+ command: |
+ INPUT=$(cat)
+ SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"')
+ echo "Session $SESSION_ID started at $(date)" >> /tmp/agent-session.log
+ echo '{"hook_specific_output":{"additional_context":"Session initialized."}}'
+
+ session_end:
+ - type: command
+ timeout: 10
+ command: |
+ INPUT=$(cat)
+ SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"')
+ REASON=$(echo "$INPUT" | jq -r '.reason // "unknown"')
+ echo "Session $SESSION_ID ended ($REASON) at $(date)" >> /tmp/agent-session.log
```
-
-
⚠️ Performance
-
-
Hooks run synchronously and can slow down agent execution. Keep hook scripts fast and efficient. Consider using suppress_output: true for logging hooks to reduce noise.
+### Response Logging with Stop Hook
+
+Log every model response for analytics or compliance:
+
+```yaml
+hooks:
+ stop:
+ - type: command
+ timeout: 10
+ command: |
+ INPUT=$(cat)
+ SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"')
+ RESPONSE_LENGTH=$(echo "$INPUT" | jq -r '.stop_response // ""' | wc -c | tr -d ' ')
+ echo "[$(date)] Session $SESSION_ID - Response: $RESPONSE_LENGTH chars" >> /tmp/agent-responses.log
+```
+
+The `stop` hook is useful for:
+
+- **Response quality checks** — validate that responses meet criteria before returning
+- **Analytics** — track response lengths, patterns, or content
+- **Compliance logging** — record all agent outputs for audit
+
+### Error Notifications
+
+Send alerts when the agent encounters errors:
+
+```yaml
+hooks:
+ notification:
+ - type: command
+ timeout: 10
+ command: |
+ INPUT=$(cat)
+ LEVEL=$(echo "$INPUT" | jq -r '.notification_level // "unknown"')
+ MESSAGE=$(echo "$INPUT" | jq -r '.notification_message // "no message"')
+ echo "[$(date)] [$LEVEL] $MESSAGE" >> /tmp/agent-notifications.log
+```
+
+The `notification` hook fires when:
+
+- The model returns an error (all models failed)
+- A degenerate tool call loop is detected
+- The maximum iteration limit is reached
diff --git a/examples/hooks_notification.yaml b/examples/hooks_notification.yaml
new file mode 100644
index 000000000..762760d47
--- /dev/null
+++ b/examples/hooks_notification.yaml
@@ -0,0 +1,40 @@
+#!/usr/bin/env docker agent run
+#
+# Notification Hook Example
+#
+# This example demonstrates the notification hook, which fires whenever
+# the agent sends a notification to the user — such as errors, warnings,
+# or when the agent pauses for user input (max iterations reached).
+#
+# The hook receives JSON on stdin with:
+# - notification_level: "error" or "warning"
+# - notification_message: the notification content
+#
+# Use cases:
+# - Send Slack/Teams notifications when errors occur
+# - Log all agent notifications for audit trails
+# - Send desktop notifications (e.g., via osascript on macOS)
+#
+# Try it:
+# - Run the agent and trigger an error condition to see the notification
+# - Check /tmp/agent-notifications.log for logged notifications
+#
+
+agents:
+ root:
+ model: openai/gpt-4o
+ description: An agent with notification hooks
+ instruction: |
+ You are a helpful assistant.
+ toolsets:
+ - type: shell
+
+ hooks:
+ notification:
+ - type: command
+ timeout: 10
+ command: |
+ INPUT=$(cat)
+ LEVEL=$(echo "$INPUT" | jq -r '.notification_level // "unknown"')
+ MESSAGE=$(echo "$INPUT" | jq -r '.notification_message // "no message"')
+ echo "[$(date)] [$LEVEL] $MESSAGE" >> /tmp/agent-notifications.log
diff --git a/examples/hooks_session_lifecycle.yaml b/examples/hooks_session_lifecycle.yaml
new file mode 100644
index 000000000..a2d4ded36
--- /dev/null
+++ b/examples/hooks_session_lifecycle.yaml
@@ -0,0 +1,42 @@
+#!/usr/bin/env docker agent run
+#
+# Session Lifecycle Hooks Example
+#
+# This example demonstrates session_start and session_end hooks.
+# These hooks run when the agent session begins and ends, allowing
+# you to set up the environment, load context, or perform cleanup.
+#
+# Try these scenarios:
+# - Start the agent and see the session start message
+# - Ask a question, then exit to see the session end message
+# - Check /tmp/agent-session.log for the session log
+#
+
+agents:
+ root:
+ model: openai/gpt-4o
+ description: An agent with session lifecycle hooks
+ instruction: |
+ You are a helpful assistant. When the user asks what happened at startup,
+ tell them about the session hooks that ran.
+ toolsets:
+ - type: shell
+
+ hooks:
+ session_start:
+ - type: command
+ timeout: 10
+ command: |
+ INPUT=$(cat)
+ SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"')
+ echo "🚀 Session $SESSION_ID started at $(date)" >> /tmp/agent-session.log
+ echo '{"hook_specific_output":{"additional_context":"Session initialized. Log file: /tmp/agent-session.log"}}'
+
+ session_end:
+ - type: command
+ timeout: 10
+ command: |
+ INPUT=$(cat)
+ SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"')
+ REASON=$(echo "$INPUT" | jq -r '.reason // "unknown"')
+ echo "👋 Session $SESSION_ID ended (reason: $REASON) at $(date)" >> /tmp/agent-session.log
diff --git a/examples/hooks_stop.yaml b/examples/hooks_stop.yaml
new file mode 100644
index 000000000..13e29de2b
--- /dev/null
+++ b/examples/hooks_stop.yaml
@@ -0,0 +1,36 @@
+#!/usr/bin/env docker agent run
+#
+# Stop Hook Example
+#
+# This example demonstrates the stop hook, which fires whenever the model
+# finishes its response and is about to return control to the user.
+#
+# The hook receives the model's final response content via stdin as JSON
+# (in the "stop_response" field), enabling use cases like:
+# - Response quality validation
+# - Logging and analytics
+# - Sending notifications when the agent replies
+#
+# Try these scenarios:
+# - Ask the agent a question and see the stop hook log the response length
+# - Check /tmp/agent-responses.log for a log of all responses
+#
+
+agents:
+ root:
+ model: openai/gpt-4o
+ description: An agent with a stop hook that logs responses
+ instruction: |
+ You are a helpful assistant. Answer questions concisely.
+ toolsets:
+ - type: shell
+
+ hooks:
+ stop:
+ - type: command
+ timeout: 10
+ command: |
+ INPUT=$(cat)
+ SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"')
+ RESPONSE_LENGTH=$(echo "$INPUT" | jq -r '.stop_response // ""' | wc -c | tr -d ' ')
+ echo "[$(date)] Session $SESSION_ID - Response length: $RESPONSE_LENGTH chars" >> /tmp/agent-responses.log
diff --git a/pkg/config/latest/types.go b/pkg/config/latest/types.go
index 679a73a70..ff052ba89 100644
--- a/pkg/config/latest/types.go
+++ b/pkg/config/latest/types.go
@@ -1291,6 +1291,12 @@ type HooksConfig struct {
// OnUserInput hooks run when the agent needs user input
OnUserInput []HookDefinition `json:"on_user_input,omitempty" yaml:"on_user_input,omitempty"`
+
+ // Stop hooks run when the model finishes responding and is about to hand control back to the user
+ Stop []HookDefinition `json:"stop,omitempty" yaml:"stop,omitempty"`
+
+ // Notification hooks run when the agent sends a notification (error, warning) to the user
+ Notification []HookDefinition `json:"notification,omitempty" yaml:"notification,omitempty"`
}
// IsEmpty returns true if no hooks are configured
@@ -1302,7 +1308,9 @@ func (h *HooksConfig) IsEmpty() bool {
len(h.PostToolUse) == 0 &&
len(h.SessionStart) == 0 &&
len(h.SessionEnd) == 0 &&
- len(h.OnUserInput) == 0
+ len(h.OnUserInput) == 0 &&
+ len(h.Stop) == 0 &&
+ len(h.Notification) == 0
}
// HookMatcherConfig represents a hook matcher with its hooks.
@@ -1365,6 +1373,20 @@ func (h *HooksConfig) validate() error {
}
}
+ // Validate Stop hooks
+ for i, hook := range h.Stop {
+ if err := hook.validate("stop", i); err != nil {
+ return err
+ }
+ }
+
+ // Validate Notification hooks
+ for i, hook := range h.Notification {
+ if err := hook.validate("notification", i); err != nil {
+ return err
+ }
+ }
+
return nil
}
diff --git a/pkg/hooks/config.go b/pkg/hooks/config.go
index b1565ac7e..af80df7ff 100644
--- a/pkg/hooks/config.go
+++ b/pkg/hooks/config.go
@@ -71,5 +71,23 @@ func FromConfig(cfg *latest.HooksConfig) *Config {
})
}
+ // Convert Stop
+ for _, h := range cfg.Stop {
+ result.Stop = append(result.Stop, Hook{
+ Type: HookType(h.Type),
+ Command: h.Command,
+ Timeout: h.Timeout,
+ })
+ }
+
+ // Convert Notification
+ for _, h := range cfg.Notification {
+ result.Notification = append(result.Notification, Hook{
+ Type: HookType(h.Type),
+ Command: h.Command,
+ Timeout: h.Timeout,
+ })
+ }
+
return result
}
diff --git a/pkg/hooks/executor.go b/pkg/hooks/executor.go
index 427a9200d..2e3dc4a4e 100644
--- a/pkg/hooks/executor.go
+++ b/pkg/hooks/executor.go
@@ -188,6 +188,28 @@ func (e *Executor) ExecuteOnUserInput(ctx context.Context, input *Input) (*Resul
return e.executeHooks(ctx, e.config.OnUserInput, input, EventOnUserInput)
}
+// ExecuteStop runs stop hooks when the model finishes responding
+func (e *Executor) ExecuteStop(ctx context.Context, input *Input) (*Result, error) {
+ if e.config == nil || len(e.config.Stop) == 0 {
+ return &Result{Allowed: true}, nil
+ }
+
+ input.HookEventName = EventStop
+
+ return e.executeHooks(ctx, e.config.Stop, input, EventStop)
+}
+
+// ExecuteNotification runs notification hooks when the agent emits a notification
+func (e *Executor) ExecuteNotification(ctx context.Context, input *Input) (*Result, error) {
+ if e.config == nil || len(e.config.Notification) == 0 {
+ return &Result{Allowed: true}, nil
+ }
+
+ input.HookEventName = EventNotification
+
+ return e.executeHooks(ctx, e.config.Notification, input, EventNotification)
+}
+
// executeHooks runs a list of hooks in parallel and aggregates results
func (e *Executor) executeHooks(ctx context.Context, hooks []Hook, input *Input, eventType EventType) (*Result, error) {
// Deduplicate hooks by command
@@ -373,7 +395,7 @@ func (e *Executor) aggregateResults(results []hookResult, eventType EventType) (
}
} else if r.stdout != "" {
// Plain text stdout is added as context for some events
- if eventType == EventSessionStart || eventType == EventPostToolUse {
+ if eventType == EventSessionStart || eventType == EventPostToolUse || eventType == EventStop {
additionalContexts = append(additionalContexts, strings.TrimSpace(r.stdout))
}
}
@@ -417,3 +439,13 @@ func (e *Executor) HasSessionEndHooks() bool {
func (e *Executor) HasOnUserInputHooks() bool {
return e.config != nil && len(e.config.OnUserInput) > 0
}
+
+// HasStopHooks returns true if there are any stop hooks configured
+func (e *Executor) HasStopHooks() bool {
+ return e.config != nil && len(e.config.Stop) > 0
+}
+
+// HasNotificationHooks returns true if there are any notification hooks configured
+func (e *Executor) HasNotificationHooks() bool {
+ return e.config != nil && len(e.config.Notification) > 0
+}
diff --git a/pkg/hooks/hooks_test.go b/pkg/hooks/hooks_test.go
index 9617de44a..4e307d45b 100644
--- a/pkg/hooks/hooks_test.go
+++ b/pkg/hooks/hooks_test.go
@@ -96,6 +96,20 @@ func TestConfigIsEmpty(t *testing.T) {
},
expected: false,
},
+ {
+ name: "with stop",
+ config: Config{
+ Stop: []Hook{{Type: HookTypeCommand}},
+ },
+ expected: false,
+ },
+ {
+ name: "with notification",
+ config: Config{
+ Notification: []Hook{{Type: HookTypeCommand}},
+ },
+ expected: false,
+ },
}
for _, tt := range tests {
@@ -221,6 +235,8 @@ func TestNewExecutor(t *testing.T) {
assert.False(t, exec.HasPostToolUseHooks())
assert.False(t, exec.HasSessionStartHooks())
assert.False(t, exec.HasSessionEndHooks())
+ assert.False(t, exec.HasStopHooks())
+ assert.False(t, exec.HasNotificationHooks())
}
func TestExecutorNilConfig(t *testing.T) {
@@ -506,6 +522,90 @@ func TestExecuteOnUserInput(t *testing.T) {
assert.True(t, result.Allowed)
}
+func TestExecuteStop(t *testing.T) {
+ t.Parallel()
+
+ config := &Config{
+ Stop: []Hook{
+ {Type: HookTypeCommand, Command: "echo 'model stopped'", Timeout: 5},
+ },
+ }
+
+ exec := NewExecutor(config, t.TempDir(), nil)
+ input := &Input{
+ SessionID: "test-session",
+ StopResponse: "Here is the answer to your question.",
+ }
+
+ result, err := exec.ExecuteStop(t.Context(), input)
+ require.NoError(t, err)
+ assert.True(t, result.Allowed)
+ assert.Contains(t, result.AdditionalContext, "model stopped")
+}
+
+func TestExecuteStopReceivesResponseContent(t *testing.T) {
+ t.Parallel()
+
+ config := &Config{
+ Stop: []Hook{
+ {Type: HookTypeCommand, Command: "cat | jq -r '.stop_response'", Timeout: 5},
+ },
+ }
+
+ exec := NewExecutor(config, t.TempDir(), nil)
+ input := &Input{
+ SessionID: "test-session",
+ StopResponse: "final answer content",
+ }
+
+ result, err := exec.ExecuteStop(t.Context(), input)
+ require.NoError(t, err)
+ assert.True(t, result.Allowed)
+ assert.Contains(t, result.AdditionalContext, "final answer content")
+}
+
+func TestExecuteNotification(t *testing.T) {
+ t.Parallel()
+
+ config := &Config{
+ Notification: []Hook{
+ {Type: HookTypeCommand, Command: "echo 'notification received'", Timeout: 5},
+ },
+ }
+
+ exec := NewExecutor(config, t.TempDir(), nil)
+ input := &Input{
+ SessionID: "test-session",
+ NotificationLevel: "error",
+ NotificationMessage: "Something went wrong",
+ }
+
+ result, err := exec.ExecuteNotification(t.Context(), input)
+ require.NoError(t, err)
+ assert.True(t, result.Allowed)
+}
+
+func TestExecuteNotificationReceivesLevel(t *testing.T) {
+ t.Parallel()
+
+ config := &Config{
+ Notification: []Hook{
+ {Type: HookTypeCommand, Command: "cat | jq -r '.notification_level'", Timeout: 5},
+ },
+ }
+
+ exec := NewExecutor(config, t.TempDir(), nil)
+ input := &Input{
+ SessionID: "test-session",
+ NotificationLevel: "warning",
+ NotificationMessage: "Watch out",
+ }
+
+ result, err := exec.ExecuteNotification(t.Context(), input)
+ require.NoError(t, err)
+ assert.True(t, result.Allowed)
+}
+
func TestExecuteHooksWithContextCancellation(t *testing.T) {
t.Parallel()
diff --git a/pkg/hooks/types.go b/pkg/hooks/types.go
index 5a792bc6f..e32e84744 100644
--- a/pkg/hooks/types.go
+++ b/pkg/hooks/types.go
@@ -32,6 +32,15 @@ const (
// OnUserInput is triggered when the agent needs input from the user.
// Can log, notify, or perform actions before user interaction.
EventOnUserInput EventType = "on_user_input"
+
+ // Stop is triggered when the model finishes its response and is about
+ // to hand control back to the user. Can perform post-response validation,
+ // logging, or cleanup.
+ EventStop EventType = "stop"
+
+ // Notification is triggered when the agent emits a notification to the user,
+ // such as errors or warnings. Can send external notifications or log events.
+ EventNotification EventType = "notification"
)
// HookType represents the type of hook action
@@ -88,6 +97,12 @@ type Config struct {
// OnUserInput hooks run when the agent needs user input
OnUserInput []Hook `json:"on_user_input,omitempty" yaml:"on_user_input,omitempty"`
+
+ // Stop hooks run when the model finishes responding
+ Stop []Hook `json:"stop,omitempty" yaml:"stop,omitempty"`
+
+ // Notification hooks run when the agent sends a notification (error, warning) to the user
+ Notification []Hook `json:"notification,omitempty" yaml:"notification,omitempty"`
}
// IsEmpty returns true if no hooks are configured
@@ -96,7 +111,9 @@ func (c *Config) IsEmpty() bool {
len(c.PostToolUse) == 0 &&
len(c.SessionStart) == 0 &&
len(c.SessionEnd) == 0 &&
- len(c.OnUserInput) == 0
+ len(c.OnUserInput) == 0 &&
+ len(c.Stop) == 0 &&
+ len(c.Notification) == 0
}
// Input represents the JSON input passed to hooks via stdin
@@ -119,6 +136,13 @@ type Input struct {
// SessionEnd specific
Reason string `json:"reason,omitempty"` // "clear", "logout", "prompt_input_exit", "other"
+
+ // Stop specific
+ StopResponse string `json:"stop_response,omitempty"` // The model's final response content
+
+ // Notification specific
+ NotificationLevel string `json:"notification_level,omitempty"` // "error" or "warning"
+ NotificationMessage string `json:"notification_message,omitempty"` // The notification content
}
// ToJSON serializes the input to JSON
diff --git a/pkg/runtime/loop.go b/pkg/runtime/loop.go
index 3f768e958..a8db664af 100644
--- a/pkg/runtime/loop.go
+++ b/pkg/runtime/loop.go
@@ -52,7 +52,13 @@ func (r *LocalRuntime) finalizeEventChannel(ctx context.Context, sess *session.S
defer close(events)
- events <- StreamStopped(sess.ID, r.resolveSessionAgent(sess).Name())
+ a := r.resolveSessionAgent(sess)
+
+ // Execute session end hooks with a context that won't be cancelled so
+ // cleanup hooks run even when the stream was interrupted (e.g. Ctrl+C).
+ r.executeSessionEndHooks(context.WithoutCancel(ctx), sess, a)
+
+ events <- StreamStopped(sess.ID, a.Name())
r.executeOnUserInputHooks(ctx, sess.ID, "stream stopped")
@@ -85,6 +91,9 @@ func (r *LocalRuntime) RunStream(ctx context.Context, sess *session.Session) <-c
a := r.resolveSessionAgent(sess)
+ // Execute session start hooks
+ r.executeSessionStartHooks(ctx, sess, a, events)
+
// Emit team information
events <- TeamInfo(r.agentDetailsFromTeam(), a.Name())
@@ -165,6 +174,10 @@ func (r *LocalRuntime) RunStream(ctx context.Context, sess *session.Session) <-c
events <- MaxIterationsReached(runtimeMaxIterations)
+ maxIterMsg := fmt.Sprintf("Maximum iterations reached (%d)", runtimeMaxIterations)
+ r.executeNotificationHooks(ctx, a, sess.ID, "warning", maxIterMsg)
+ r.executeOnUserInputHooks(ctx, sess.ID, "max iterations reached")
+
// Wait for user decision (resume / reject)
select {
case req := <-r.resumeChan:
@@ -307,7 +320,9 @@ func (r *LocalRuntime) RunStream(ctx context.Context, sess *session.Session) <-c
slog.Error("All models failed", "agent", a.Name(), "error", err)
// Track error in telemetry
telemetry.RecordError(ctx, err.Error())
- events <- Error(modelerrors.FormatError(err))
+ errMsg := modelerrors.FormatError(err)
+ events <- Error(errMsg)
+ r.executeNotificationHooks(ctx, a, sess.ID, "error", errMsg)
streamSpan.End()
return
}
@@ -345,10 +360,12 @@ func (r *LocalRuntime) RunStream(ctx context.Context, sess *session.Session) <-c
slog.Warn("Repetitive tool call loop detected",
"agent", a.Name(), "tool", toolName,
"consecutive", loopDetector.consecutive, "session_id", sess.ID)
- events <- Error(fmt.Sprintf(
+ errMsg := fmt.Sprintf(
"Agent terminated: detected %d consecutive identical calls to %s. "+
"This indicates a degenerate loop where the model is not making progress.",
- loopDetector.consecutive, toolName))
+ loopDetector.consecutive, toolName)
+ events <- Error(errMsg)
+ r.executeNotificationHooks(ctx, a, sess.ID, "error", errMsg)
return
}
@@ -357,6 +374,7 @@ func (r *LocalRuntime) RunStream(ctx context.Context, sess *session.Session) <-c
if res.Stopped {
slog.Debug("Conversation stopped", "agent", a.Name())
+ r.executeStopHooks(ctx, sess, a, res.Content, events)
break
}
diff --git a/pkg/runtime/runtime.go b/pkg/runtime/runtime.go
index 5d01d55f8..a01928dae 100644
--- a/pkg/runtime/runtime.go
+++ b/pkg/runtime/runtime.go
@@ -649,6 +649,114 @@ func (r *LocalRuntime) getHooksExecutor(a *agent.Agent) *hooks.Executor {
return hooks.NewExecutor(hooksCfg, r.workingDir, r.env)
}
+// executeSessionStartHooks executes session start hooks for the given agent.
+// It logs the hook output as additional context and emits warnings for system messages.
+func (r *LocalRuntime) executeSessionStartHooks(ctx context.Context, sess *session.Session, a *agent.Agent, events chan Event) {
+ hooksExec := r.getHooksExecutor(a)
+ if hooksExec == nil || !hooksExec.HasSessionStartHooks() {
+ return
+ }
+
+ slog.Debug("Executing session start hooks", "agent", a.Name(), "session_id", sess.ID)
+ input := &hooks.Input{
+ SessionID: sess.ID,
+ Cwd: r.workingDir,
+ Source: "startup",
+ }
+
+ result, err := hooksExec.ExecuteSessionStart(ctx, input)
+ if err != nil {
+ slog.Warn("Session start hook execution failed", "agent", a.Name(), "error", err)
+ return
+ }
+
+ if result.SystemMessage != "" {
+ events <- Warning(result.SystemMessage, a.Name())
+ }
+ if result.AdditionalContext != "" {
+ slog.Debug("Session start hook provided additional context", "context", result.AdditionalContext)
+ sess.AddMessage(session.SystemMessage(result.AdditionalContext))
+ }
+}
+
+// executeSessionEndHooks executes session end hooks for the given agent.
+func (r *LocalRuntime) executeSessionEndHooks(ctx context.Context, sess *session.Session, a *agent.Agent) {
+ hooksExec := r.getHooksExecutor(a)
+ if hooksExec == nil || !hooksExec.HasSessionEndHooks() {
+ return
+ }
+
+ slog.Debug("Executing session end hooks", "agent", a.Name(), "session_id", sess.ID)
+ input := &hooks.Input{
+ SessionID: sess.ID,
+ Cwd: r.workingDir,
+ Reason: "stream_ended",
+ }
+
+ _, err := hooksExec.ExecuteSessionEnd(ctx, input)
+ if err != nil {
+ slog.Error("Session end hook execution failed", "agent", a.Name(), "error", err)
+ }
+}
+
+// executeStopHooks executes stop hooks when the model finishes responding.
+// The stop hook receives the model's final response content.
+func (r *LocalRuntime) executeStopHooks(ctx context.Context, sess *session.Session, a *agent.Agent, responseContent string, events chan Event) {
+ hooksExec := r.getHooksExecutor(a)
+ if hooksExec == nil || !hooksExec.HasStopHooks() {
+ return
+ }
+
+ slog.Debug("Executing stop hooks", "agent", a.Name(), "session_id", sess.ID)
+ input := &hooks.Input{
+ SessionID: sess.ID,
+ Cwd: r.workingDir,
+ StopResponse: responseContent,
+ }
+
+ result, err := hooksExec.ExecuteStop(ctx, input)
+ if err != nil {
+ slog.Warn("Stop hook execution failed", "agent", a.Name(), "error", err)
+ return
+ }
+
+ if result.SystemMessage != "" {
+ events <- Warning(result.SystemMessage, a.Name())
+ }
+}
+
+// executeNotificationHooks executes notification hooks when the agent emits a user-facing
+// notification (e.g., errors or warnings). Hook output is logged but does not affect the
+// notification itself. Individual hooks are subject to their configured timeout.
+func (r *LocalRuntime) executeNotificationHooks(ctx context.Context, a *agent.Agent, sessionID, level, message string) {
+ if a == nil {
+ return
+ }
+
+ if level != "error" && level != "warning" {
+ slog.Error("Invalid notification level", "level", level, "expected", "error|warning")
+ return
+ }
+
+ hooksExec := r.getHooksExecutor(a)
+ if hooksExec == nil || !hooksExec.HasNotificationHooks() {
+ return
+ }
+
+ slog.Debug("Executing notification hooks", "level", level, "session_id", sessionID)
+ input := &hooks.Input{
+ SessionID: sessionID,
+ Cwd: r.workingDir,
+ NotificationLevel: level,
+ NotificationMessage: message,
+ }
+
+ _, err := hooksExec.ExecuteNotification(ctx, input)
+ if err != nil {
+ slog.Warn("Notification hook execution failed", "error", err)
+ }
+}
+
// executeOnUserInputHooks executes on-user-input hooks for the current agent
func (r *LocalRuntime) executeOnUserInputHooks(ctx context.Context, sessionID, logContext string) {
a, _ := r.team.Agent(r.CurrentAgentName())