Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions agent-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1051,6 +1051,20 @@
"$ref": "#/definitions/HookDefinition"
}
},
"user_steering_messages_submit": {
"type": "array",
"description": "Hooks that run each time the runtime drains the steering queue and appends the queued user messages to the session \u2014 messages the user submitted while the agent was already working (mid-turn, after the model stopped, or while idle before the first model call). The drained messages are passed in the steering_messages field. Like user_prompt_submit, hooks can block the run (decision=block / continue=false / exit code 2) or contribute additional_context that is spliced into the conversation as a transient system message for the steered turn only \u2014 it is NOT persisted to the session.",
"items": {
"$ref": "#/definitions/HookDefinition"
}
},
"user_followup_submit": {
"type": "array",
"description": "Hooks that run each time the runtime dequeues a follow-up message at the end of a turn and starts a fresh turn for it. Follow-ups are user messages queued for end-of-turn processing (the FollowUp API / queue), distinct from mid-turn steering \u2014 the model sees them as fresh input. The follow-up text is passed in the prompt field. Like user_prompt_submit, hooks can block the run (decision=block / continue=false / exit code 2) or contribute additional_context that is spliced into the conversation as a transient system message for the follow-up turn only \u2014 it is NOT persisted to the session.",
"items": {
"$ref": "#/definitions/HookDefinition"
}
},
"turn_start": {
"type": "array",
"description": "Hooks that run at the start of every agent turn (each model call). Their AdditionalContext is appended as transient system messages for that turn only \u2014 it is NOT persisted to the session, so per-turn signals (date, prompt files) are recomputed every turn instead of bloating message history on every resume.",
Expand Down
54 changes: 52 additions & 2 deletions docs/configuration/hooks/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ docker-agent dispatches the following hook events:
| `permission_request` | Just before the runtime would prompt the user to approve a tool | Yes |
| `session_start` | When a session begins or resumes | No |
| `user_prompt_submit` | Once per user message, after submission and before the model runs | Yes |
| `user_steering_messages_submit` | Each time queued steering messages are drained (mid-turn, after stop, or while idle) | Yes |
| `user_followup_submit` | Each time a queued follow-up message starts a fresh turn (end-of-turn) | Yes |
| `turn_start` | At the start of every agent turn (each model call) | No |
| `turn_end` | At the end of every agent turn — fires no matter why the turn ended | No |
| `before_llm_call` | Just before every model call (after `turn_start`) | Yes |
Expand Down Expand Up @@ -256,6 +258,8 @@ In addition to the common fields, each event ships its own payload:
| `permission_request` | `tool_name`, `tool_use_id`, `tool_input` |
| `session_start` | `source` — one of `startup`, `resume`, `clear`, `compact` |
| `user_prompt_submit` | `prompt` — the text the user just submitted |
| `user_steering_messages_submit` | `steering_messages` — the drained steering messages, in submission order |
| `user_followup_submit` | `prompt` — the text of the dequeued follow-up message |
| `turn_start` | _none_ (just the common fields) |
| `turn_end` | `agent_name`, `reason` — one of `normal`, `continue`, `steered`, `error`, `canceled`, `hook_blocked`, `loop_detected` |
| `before_llm_call` | `iteration` — 1-based run-loop iteration counter (the model call this hook is gating), `model_id` |
Expand All @@ -279,6 +283,8 @@ Notes:

- `tool_response` for `post_tool_use` carries the tool's result; `tool_error` is `true` when the tool failed (the failure detail is surfaced inside `tool_response`).
- `prompt` is only populated for `user_prompt_submit`. Sub-sessions (transferred tasks, background agents, skills) do **not** fire this event because their kick-off message is synthesised by the runtime, not authored by the user.
- `steering_messages` is only populated for `user_steering_messages_submit`. It carries the user messages the runtime just drained from the steering queue — messages submitted while the agent was already working (mid-turn, after the model stopped, or while idle before the first model call).
- `prompt` is also populated for `user_followup_submit`, carrying the text of the dequeued follow-up message (a user message queued for end-of-turn processing via the FollowUp API / queue, as opposed to mid-turn steering).
- `stop_response` carries the model's final assistant text for `stop`, `after_llm_call`, and `subagent_stop`. `last_user_message` carries the latest user message at dispatch time.
- `model_id` is populated for `after_llm_call` (and `before_llm_call`) in the canonical `<provider>/<model>` form (e.g. `anthropic/claude-sonnet-4-5`). For harness agents, `model_id` is the harness label (e.g. `claude-code`) rather than a canonical model name — see [Coding Harnesses]({{ '/features/harnesses/' | relative_url }}).
- `context_limit` is `0` when the model definition is unavailable (treat `0` as "unknown", not as a real limit).
Expand Down Expand Up @@ -340,7 +346,7 @@ This is the symmetric counterpart of `pre_tool_use`'s `updated_input`, applied t

### Context-Contributing Events

For `session_start`, `user_prompt_submit`, `turn_start`, `post_tool_use`, `pre_compact`, and `stop`, hooks may set `hook_specific_output.additional_context` to inject text into the conversation. `turn_start` context is **transient** (recomputed every turn, never persisted); `session_start` context **persists** for the life of the session. (`worktree_create` also surfaces stdout, but to the CLI user rather than the conversation — the session doesn't exist yet.)
For `session_start`, `user_prompt_submit`, `user_steering_messages_submit`, `user_followup_submit`, `turn_start`, `post_tool_use`, `pre_compact`, and `stop`, hooks may set `hook_specific_output.additional_context` to inject text into the conversation. `turn_start` context is **transient** (recomputed every turn, never persisted); `session_start` context **persists** for the life of the session. `user_steering_messages_submit` and `user_followup_submit` context is **transient** like `user_prompt_submit` — it is spliced into the steered/follow-up turn only and never persisted. (`worktree_create` also surfaces stdout, but to the CLI user rather than the conversation — the session doesn't exist yet.)

### Before-Compaction Specific Output

Expand All @@ -359,7 +365,7 @@ Returning `decision: "block"` (or exit code 2) instead vetoes the compaction ent

### Plain Text Output

For `session_start`, `user_prompt_submit`, `turn_start`, `post_tool_use`, `pre_compact`, and `stop` hooks, plain text written to stdout (i.e., output that is not valid JSON) is captured as additional context for the agent. For `pre_compact` it is appended to the compaction prompt; for the others it is spliced into the conversation as a (transient or persisted) system message depending on the event.
For `session_start`, `user_prompt_submit`, `user_steering_messages_submit`, `user_followup_submit`, `turn_start`, `post_tool_use`, `pre_compact`, and `stop` hooks, plain text written to stdout (i.e., output that is not valid JSON) is captured as additional context for the agent. For `pre_compact` it is appended to the compaction prompt; for the others it is spliced into the conversation as a (transient or persisted) system message depending on the event.

## Exit Codes

Expand Down Expand Up @@ -653,6 +659,50 @@ Return `additional_context` (or plain stdout) to append guidance to the compacti

It does **not** fire for sub-sessions (transferred tasks, background agents, skill sub-sessions) because their kick-off message is synthesised by the runtime.

### User-Steering-Messages-Submit: gate or enrich mid-flight steering

`user_steering_messages_submit` is the steering-queue analogue of `user_prompt_submit`. It fires each time the runtime drains the steering queue — messages the user submitted while the agent was already working: mid-turn (after a batch of tool calls), after the model stopped, or while idle before the first model call. The drained messages arrive as a JSON array in `steering_messages`. Use it to:

- block a run when steering violates policy (`decision: block` / exit code 2),
- inject context in response to the steering (`additional_context` is spliced as a transient system message for the steered turn — never persisted, exactly like `user_prompt_submit`),
- audit steering messages to a log.

Unlike `turn_end` with `reason: steered`, which only observes the mid-turn and post-stop drains, this event fires on **every** drain — including steering applied while the agent was idle before its first model call.

```yaml
hooks:
user_steering_messages_submit:
- type: command
timeout: 5
command: |
INPUT=$(cat)
COUNT=$(echo "$INPUT" | jq -r '.steering_messages | length')
echo "$INPUT" | jq -r '.steering_messages[]' >> /tmp/agent-steering.log
if [ "$COUNT" -gt 0 ]; then
echo '{"hook_specific_output":{"additional_context":"The user sent new instructions while you were working — re-read the latest user messages and adjust course before continuing."}}'
fi
```

### User-Followup-Submit: gate or enrich queued follow-ups

`user_followup_submit` is the follow-up-queue analogue of `user_prompt_submit`. It fires each time the runtime dequeues a follow-up message at the end of a turn and starts a fresh turn for it. Follow-ups are user messages queued for end-of-turn processing (the FollowUp API / queue) — distinct from mid-turn steering: the model sees a follow-up as fresh input, not an interruption, and each follow-up gets a full undivided turn. The follow-up text is in `prompt`. Use it to:

- block a queued follow-up that violates policy (`decision: block` / exit code 2),
- inject per-follow-up context (`additional_context` is spliced as a transient system message for the follow-up turn — never persisted, exactly like `user_prompt_submit`),
- audit follow-up messages to a log.

This closes the gap left by `user_prompt_submit`, which fires only for the first interactive prompt and never for queued follow-ups.

```yaml
hooks:
user_followup_submit:
- type: command
timeout: 5
command: |
INPUT=$(cat)
echo "$INPUT" | jq -r '.prompt' >> /tmp/agent-followups.log
```

### Subagent-Stop: observe handoff completions

`subagent_stop` fires whenever a sub-agent finishes — `transfer_task` returns, a background agent completes, or a skill sub-session ends. It runs against the *parent* agent's hooks executor, so handlers configured on the orchestrator see every child completion in one place. The sub-agent's name is in `agent_name`, the parent's session ID in `parent_session_id`, and the child's final assistant message in `stop_response`.
Expand Down
54 changes: 54 additions & 0 deletions examples/hooks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@
# prompting the user
# session_start - one-time setup; AdditionalContext PERSISTS in the session
# user_prompt_submit- runs once per user message, before the first LLM call
# user_steering_messages_submit
# - runs each time queued steering messages are drained
# (mid-turn, after stop, or while idle); AdditionalContext
# is TRANSIENT, like user_prompt_submit
# user_followup_submit
# - runs each time a queued follow-up message starts a fresh
# turn at end-of-turn; AdditionalContext is TRANSIENT
# turn_start - per-turn context; AdditionalContext is TRANSIENT
# turn_end - per-turn finalizer; fires no matter why the turn ended
# before_llm_call - just before each model call (observability, guardrails)
Expand Down Expand Up @@ -64,6 +71,8 @@
# Logs (tail these in another terminal):
# /tmp/agent-session.log (session_start, session_end)
# /tmp/agent-prompts.log (user_prompt_submit)
# /tmp/agent-steering.log (user_steering_messages_submit)
# /tmp/agent-followups.log (user_followup_submit)
# /tmp/agent-llm-calls.log (before_llm_call, after_llm_call)
# /tmp/agent-turns.log (turn_end)
# /tmp/agent-tool-results.log (post_tool_use)
Expand Down Expand Up @@ -209,6 +218,51 @@ agents:
echo '{"hook_specific_output":{"additional_context":"Hook hint: agent log files live under /tmp/agent-*.log"}}'
fi

# ====================================================================
# USER-STEERING-MESSAGES-SUBMIT - runs each time the runtime drains
# the steering queue, i.e. messages the user submitted while the
# agent was already working (mid-turn, after the model stopped, or
# while idle before the first LLM call). The drained messages arrive
# as a JSON array in the steering_messages field. This is the
# steering-queue analogue of user_prompt_submit: you can block the
# run (decision=block / continue=false / exit code 2) or inject
# additional_context, which becomes a TRANSIENT system message for
# the steered turn only (never persisted).
# ====================================================================
user_steering_messages_submit:
- type: command
timeout: 5
command: |
INPUT=$(cat)
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"')
COUNT=$(echo "$INPUT" | jq -r '.steering_messages | length')
echo "$INPUT" | jq -r '.steering_messages[]' \
| sed "s/^/[$(date)] [$SESSION_ID] steer: /" >> /tmp/agent-steering.log
# Example: nudge the model to acknowledge mid-flight steering.
if [ "$COUNT" -gt 0 ]; then
echo '{"hook_specific_output":{"additional_context":"The user sent new instructions while you were working — re-read the latest user messages and adjust course before continuing."}}'
fi

# ====================================================================
# USER-FOLLOWUP-SUBMIT - runs each time the runtime dequeues a
# follow-up message at the end of a turn and starts a fresh turn
# for it. Follow-ups are user messages queued for end-of-turn
# processing (the FollowUp API / queue), distinct from mid-turn
# steering: the model sees them as fresh input. The follow-up text
# is in the prompt field. Like user_prompt_submit, you can block
# the run (decision=block / continue=false / exit code 2) or inject
# additional_context, which becomes a TRANSIENT system message for
# the follow-up turn only (never persisted).
# ====================================================================
user_followup_submit:
- type: command
timeout: 5
command: |
INPUT=$(cat)
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"')
echo "$INPUT" | jq -r '.prompt' \
| sed "s/^/[$(date)] [$SESSION_ID] followup: /" >> /tmp/agent-followups.log

# ====================================================================
# TURN-START - runs at the start of every model call.
# Result.AdditionalContext is spliced after the invariant cache
Expand Down
39 changes: 39 additions & 0 deletions pkg/config/latest/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -1964,6 +1964,29 @@ type HooksConfig struct {
// runtime, not authored by the user.
UserPromptSubmit []HookDefinition `json:"user_prompt_submit,omitempty" yaml:"user_prompt_submit,omitempty"`

// UserSteeringMessagesSubmit hooks run once each time the runtime
// drains the steering queue and appends the queued user messages to
// the session — i.e. messages the user submitted while the agent was
// already working (mid-turn, after the model stopped, or while idle
// before the first model call). The drained messages are passed in
// the steering_messages field. Like user_prompt_submit, hooks can
// block the run (decision="block" / continue=false / exit code 2) or
// contribute additional_context that is spliced into the conversation
// as a transient system message for the steered turn only — it is NOT
// persisted to the session.
UserSteeringMessagesSubmit []HookDefinition `json:"user_steering_messages_submit,omitempty" yaml:"user_steering_messages_submit,omitempty"`

// UserFollowupSubmit hooks run once each time the runtime dequeues a
// follow-up message at the end of a turn and starts a fresh turn for
// it. Follow-ups are user messages queued for end-of-turn processing
// (the FollowUp API / queue), as opposed to mid-turn steering. The
// follow-up text is passed in the prompt field. Like
// user_prompt_submit, hooks can block the run (decision="block" /
// continue=false / exit code 2) or contribute additional_context that
// is spliced into the conversation as a transient system message for
// the follow-up turn only — it is NOT persisted to the session.
UserFollowupSubmit []HookDefinition `json:"user_followup_submit,omitempty" yaml:"user_followup_submit,omitempty"`

// TurnStart hooks run at the start of every agent turn (each model
// call). Their AdditionalContext is appended as transient system
// messages for that turn only — it is NOT persisted to the session,
Expand Down Expand Up @@ -2097,6 +2120,8 @@ func (h *HooksConfig) IsEmpty() bool {
len(h.PermissionRequest) == 0 &&
len(h.SessionStart) == 0 &&
len(h.UserPromptSubmit) == 0 &&
len(h.UserSteeringMessagesSubmit) == 0 &&
len(h.UserFollowupSubmit) == 0 &&
len(h.TurnStart) == 0 &&
len(h.TurnEnd) == 0 &&
len(h.BeforeLLMCall) == 0 &&
Expand Down Expand Up @@ -2249,6 +2274,20 @@ func (h *HooksConfig) Validate() error {
}
}

// Validate UserSteeringMessagesSubmit hooks
for i, hook := range h.UserSteeringMessagesSubmit {
if err := hook.validate("user_steering_messages_submit", i); err != nil {
return err
}
}

// Validate UserFollowupSubmit hooks
for i, hook := range h.UserFollowupSubmit {
if err := hook.validate("user_followup_submit", i); err != nil {
return err
}
}

// Validate TurnStart hooks
for i, hook := range h.TurnStart {
if err := hook.validate("turn_start", i); err != nil {
Expand Down
Loading
Loading