diff --git a/agent-schema.json b/agent-schema.json
index 27c30049a..b229778e8 100644
--- a/agent-schema.json
+++ b/agent-schema.json
@@ -390,6 +390,13 @@
"items": {
"$ref": "#/definitions/HookDefinition"
}
+ },
+ "on_user_input": {
+ "type": "array",
+ "description": "Hooks that run when the agent needs user input. Can send notifications or log events.",
+ "items": {
+ "$ref": "#/definitions/HookDefinition"
+ }
}
},
"additionalProperties": false
diff --git a/docs/TODO.md b/docs/TODO.md
index 44a67ec28..e8fb81262 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -3,7 +3,7 @@
## New Pages
- [x] **Go SDK** — Not documented anywhere. The `examples/golibrary/` directory shows how to use cagent as a Go library. Needs a dedicated page. *(Completed: pages/guides/go-sdk.html)*
-- [x] **Hooks** — `hooks` agent config (`pre_tool_use`, `post_tool_use`, `session_start`, `session_end`) is a significant feature with no documentation page. Covers running shell commands at various agent lifecycle points. *(Completed: pages/configuration/hooks.html)*
+- [x] **Hooks** — `hooks` agent config (`pre_tool_use`, `post_tool_use`, `session_start`, `session_end`, `on_user_input`) is a significant feature with no documentation page. Covers running shell commands at various agent lifecycle points. *(Completed: pages/configuration/hooks.html)*
- [x] **Permissions** — Top-level `permissions` config with `allow`/`deny` glob patterns for tool call approval. Mentioned briefly in TUI page but has no dedicated reference. *(Completed: pages/configuration/permissions.html)*
- [x] **Sandbox Mode** — Shell tool `sandbox` config runs commands in Docker containers. Includes `image` and `paths` (bind mounts with `:ro` support). Not documented. *(Completed: pages/configuration/sandbox.html)*
- [x] **Structured Output** — Agent-level `structured_output` config (name, description, schema, strict). Forces model responses into a JSON schema. Not documented. *(Completed: pages/configuration/structured-output.html)*
diff --git a/docs/pages/configuration/agents.html b/docs/pages/configuration/agents.html
index 11984a7f1..1152e0467 100644
--- a/docs/pages/configuration/agents.html
+++ b/docs/pages/configuration/agents.html
@@ -33,6 +33,7 @@
Full Schema
post_tool_use: [list]
session_start: [list]
session_end: [list]
+ on_user_input: [list]
permissions: # Optional: tool execution control
allow: [list]
deny: [list]
diff --git a/docs/pages/configuration/hooks.html b/docs/pages/configuration/hooks.html
index bfc1bef90..29b4dffc1 100644
--- a/docs/pages/configuration/hooks.html
+++ b/docs/pages/configuration/hooks.html
@@ -18,7 +18,7 @@ Overview
Hook Types
-There are four hook event types:
+There are five hook event types:
| Event | When it fires | Can block? |
@@ -27,6 +27,7 @@ Hook Types
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 |
@@ -61,7 +62,12 @@ Configuration
# Run when session ends
session_end:
- type: command
- command: "./scripts/cleanup.sh"
+ command: "./scripts/cleanup.sh"
+
+ # Run when agent is waiting for user input
+ on_user_input:
+ - type: command
+ command: "./scripts/notify.sh"
Matcher Patterns
@@ -96,17 +102,17 @@ Hook Input
Input Fields by Event Type
- | Field | pre_tool_use | post_tool_use | session_start | session_end |
+ | 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 | | | | ✓ |
+ session_id | ✓ | ✓ | ✓ | ✓ | ✓ |
+ cwd | ✓ | ✓ | ✓ | ✓ | ✓ |
+ hook_event_name | ✓ | ✓ | ✓ | ✓ | ✓ |
+ tool_name | ✓ | ✓ | | | |
+ tool_use_id | ✓ | ✓ | | | |
+ tool_input | ✓ | ✓ | | | |
+ tool_response | | ✓ | | | |
+ source | | | ✓ | | |
+ reason | | | | ✓ | |
diff --git a/examples/hooks.yaml b/examples/hooks.yaml
index a7bfd02f9..5b24b15c1 100644
--- a/examples/hooks.yaml
+++ b/examples/hooks.yaml
@@ -94,3 +94,13 @@ agents:
session_end:
- type: command
command: echo "👋 Session ended at $(date)"
+
+ # ============================================================
+ # USER INPUT HOOKS - Run when agent needs user input
+ # ============================================================
+ on_user_input:
+ # Example: Notify when user input is requested
+ - type: command
+ command: |
+ # Send notification (macOS only - remove or adapt for other platforms)
+ osascript -e 'display notification "ready!" with title "cagent"'
diff --git a/pkg/config/latest/types.go b/pkg/config/latest/types.go
index 71ba84883..a8092b266 100644
--- a/pkg/config/latest/types.go
+++ b/pkg/config/latest/types.go
@@ -1211,6 +1211,9 @@ type HooksConfig struct {
// SessionEnd hooks run when a session ends
SessionEnd []HookDefinition `json:"session_end,omitempty" yaml:"session_end,omitempty"`
+
+ // OnUserInput hooks run when the agent needs user input
+ OnUserInput []HookDefinition `json:"on_user_input,omitempty" yaml:"on_user_input,omitempty"`
}
// IsEmpty returns true if no hooks are configured
@@ -1221,7 +1224,8 @@ func (h *HooksConfig) IsEmpty() bool {
return len(h.PreToolUse) == 0 &&
len(h.PostToolUse) == 0 &&
len(h.SessionStart) == 0 &&
- len(h.SessionEnd) == 0
+ len(h.SessionEnd) == 0 &&
+ len(h.OnUserInput) == 0
}
// HookMatcherConfig represents a hook matcher with its hooks.
@@ -1277,6 +1281,13 @@ func (h *HooksConfig) validate() error {
}
}
+ // Validate OnUserInput hooks
+ for i, hook := range h.OnUserInput {
+ if err := hook.validate("on_user_input", i); err != nil {
+ return err
+ }
+ }
+
return nil
}
diff --git a/pkg/hooks/config.go b/pkg/hooks/config.go
index 2fd7ba8fc..299cc5fe7 100644
--- a/pkg/hooks/config.go
+++ b/pkg/hooks/config.go
@@ -62,5 +62,14 @@ func FromConfig(cfg *latest.HooksConfig) *Config {
})
}
+ // Convert OnUserInput
+ for _, h := range cfg.OnUserInput {
+ result.OnUserInput = append(result.OnUserInput, 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 a673a32e3..46dc243f6 100644
--- a/pkg/hooks/executor.go
+++ b/pkg/hooks/executor.go
@@ -191,6 +191,17 @@ func (e *Executor) ExecuteSessionEnd(ctx context.Context, input *Input) (*Result
return e.executeHooks(ctx, e.config.SessionEnd, input, EventSessionEnd)
}
+// ExecuteOnUserInput runs on-user-input hooks
+func (e *Executor) ExecuteOnUserInput(ctx context.Context, input *Input) (*Result, error) {
+ if e.config == nil || len(e.config.OnUserInput) == 0 {
+ return &Result{Allowed: true}, nil
+ }
+
+ input.HookEventName = EventOnUserInput
+
+ return e.executeHooks(ctx, e.config.OnUserInput, input, EventOnUserInput)
+}
+
// 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
@@ -415,3 +426,8 @@ func (e *Executor) HasSessionStartHooks() bool {
func (e *Executor) HasSessionEndHooks() bool {
return e.config != nil && len(e.config.SessionEnd) > 0
}
+
+// HasOnUserInputHooks returns true if there are any on-user-input hooks configured
+func (e *Executor) HasOnUserInputHooks() bool {
+ return e.config != nil && len(e.config.OnUserInput) > 0
+}
diff --git a/pkg/hooks/hooks_test.go b/pkg/hooks/hooks_test.go
index 7166bd67d..9617de44a 100644
--- a/pkg/hooks/hooks_test.go
+++ b/pkg/hooks/hooks_test.go
@@ -89,6 +89,13 @@ func TestConfigIsEmpty(t *testing.T) {
},
expected: false,
},
+ {
+ name: "with on_user_input",
+ config: Config{
+ OnUserInput: []Hook{{Type: HookTypeCommand}},
+ },
+ expected: false,
+ },
}
for _, tt := range tests {
@@ -480,6 +487,25 @@ func TestExecuteSessionEnd(t *testing.T) {
assert.True(t, result.Allowed)
}
+func TestExecuteOnUserInput(t *testing.T) {
+ t.Parallel()
+
+ config := &Config{
+ OnUserInput: []Hook{
+ {Type: HookTypeCommand, Command: "echo 'user input needed'", Timeout: 5},
+ },
+ }
+
+ exec := NewExecutor(config, t.TempDir(), nil)
+ input := &Input{
+ SessionID: "test-session",
+ }
+
+ result, err := exec.ExecuteOnUserInput(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 f9858f233..5a792bc6f 100644
--- a/pkg/hooks/types.go
+++ b/pkg/hooks/types.go
@@ -28,6 +28,10 @@ const (
// SessionEnd is triggered when a session terminates.
// Can perform cleanup, logging, persist session state.
EventSessionEnd EventType = "session_end"
+
+ // 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"
)
// HookType represents the type of hook action
@@ -81,6 +85,9 @@ type Config struct {
// SessionEnd hooks run when a session ends
SessionEnd []Hook `json:"session_end,omitempty" yaml:"session_end,omitempty"`
+
+ // OnUserInput hooks run when the agent needs user input
+ OnUserInput []Hook `json:"on_user_input,omitempty" yaml:"on_user_input,omitempty"`
}
// IsEmpty returns true if no hooks are configured
@@ -88,7 +95,8 @@ func (c *Config) IsEmpty() bool {
return len(c.PreToolUse) == 0 &&
len(c.PostToolUse) == 0 &&
len(c.SessionStart) == 0 &&
- len(c.SessionEnd) == 0
+ len(c.SessionEnd) == 0 &&
+ len(c.OnUserInput) == 0
}
// Input represents the JSON input passed to hooks via stdin
diff --git a/pkg/runtime/runtime.go b/pkg/runtime/runtime.go
index 45950b832..a6dc4313f 100644
--- a/pkg/runtime/runtime.go
+++ b/pkg/runtime/runtime.go
@@ -615,6 +615,33 @@ func (r *LocalRuntime) getHooksExecutor(a *agent.Agent) *hooks.Executor {
return hooks.NewExecutor(hooksCfg, r.workingDir, r.env)
}
+// 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.currentAgent)
+ if a == nil {
+ return
+ }
+
+ hooksExec := r.getHooksExecutor(a)
+ if hooksExec == nil || !hooksExec.HasOnUserInputHooks() {
+ return
+ }
+
+ slog.Debug("Executing on-user-input hooks", "context", logContext)
+ input := &hooks.Input{
+ SessionID: sessionID,
+ Cwd: r.workingDir,
+ }
+
+ result, err := hooksExec.ExecuteOnUserInput(ctx, input)
+ if err != nil {
+ slog.Warn("On-user-input hook execution failed", "error", err)
+ } else {
+ slog.Debug("On-user-input hooks executed successfully")
+ }
+ _ = result // Hook result not used
+}
+
// getAgentModelID returns the model ID for an agent, or empty string if no model is set.
func getAgentModelID(a *agent.Agent) string {
if model := a.Model(); model != nil {
@@ -867,6 +894,8 @@ func (r *LocalRuntime) finalizeEventChannel(ctx context.Context, sess *session.S
events <- StreamStopped(sess.ID, r.currentAgent)
+ r.executeOnUserInputHooks(ctx, sess.ID, "stream stopped")
+
telemetry.RecordSessionEnd(ctx)
}
@@ -1619,6 +1648,8 @@ func (r *LocalRuntime) askUserForConfirmation(
slog.Debug("Tools not approved, waiting for resume", "tool", toolName, "session_id", sess.ID)
events <- ToolCallConfirmation(toolCall, tool, a.Name())
+ r.executeOnUserInputHooks(ctx, sess.ID, "tool confirmation")
+
select {
case req := <-r.resumeChan:
switch req.Type {
@@ -2022,6 +2053,8 @@ func (r *LocalRuntime) elicitationHandler(ctx context.Context, req *mcp.ElicitPa
return tools.ElicitationResult{}, fmt.Errorf("no events channel available for elicitation")
}
+ r.executeOnUserInputHooks(ctx, "", "elicitation")
+
slog.Debug("Sending elicitation request event to client", "message", req.Message, "mode", req.Mode, "requested_schema", req.RequestedSchema, "url", req.URL)
slog.Debug("Elicitation request meta", "meta", req.Meta)