diff --git a/cmd/root/flags.go b/cmd/root/flags.go
index ea352b35d..de1ba4115 100644
--- a/cmd/root/flags.go
+++ b/cmd/root/flags.go
@@ -30,6 +30,11 @@ func addRuntimeConfigFlags(cmd *cobra.Command, runConfig *config.RuntimeConfig)
cmd.PersistentFlags().StringSliceVar(&runConfig.EnvFiles, "env-from-file", nil, "Set environment variables from file")
cmd.PersistentFlags().BoolVar(&runConfig.GlobalCodeMode, "code-mode-tools", false, "Provide a single tool to call other tools via Javascript")
cmd.PersistentFlags().StringVar(&runConfig.WorkingDir, "working-dir", "", "Set the working directory for the session (applies to tools and relative paths)")
+ cmd.PersistentFlags().StringArrayVar(&runConfig.HookPreToolUse, "hook-pre-tool-use", nil, "Add a pre-tool-use hook command that runs before every tool call (repeatable)")
+ cmd.PersistentFlags().StringArrayVar(&runConfig.HookPostToolUse, "hook-post-tool-use", nil, "Add a post-tool-use hook command that runs after every tool call (repeatable)")
+ cmd.PersistentFlags().StringArrayVar(&runConfig.HookSessionStart, "hook-session-start", nil, "Add a session-start hook command (repeatable)")
+ cmd.PersistentFlags().StringArrayVar(&runConfig.HookSessionEnd, "hook-session-end", nil, "Add a session-end hook command (repeatable)")
+ cmd.PersistentFlags().StringArrayVar(&runConfig.HookOnUserInput, "hook-on-user-input", nil, "Add an on-user-input hook command (repeatable)")
}
func setupWorkingDirectory(workingDir string) error {
diff --git a/docs/configuration/hooks/index.md b/docs/configuration/hooks/index.md
index b7b17adc0..d7669727e 100644
--- a/docs/configuration/hooks/index.md
+++ b/docs/configuration/hooks/index.md
@@ -242,3 +242,38 @@ hooks:
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.
+
+## CLI Flags
+
+You can add hooks from the command line without modifying the agent's YAML file. This is useful for one-off debugging, audit logging, or layering hooks onto an existing agent.
+
+| Flag | Description |
+| ----------------------- | --------------------------------------- |
+| `--hook-pre-tool-use` | Run a command before every tool call |
+| `--hook-post-tool-use` | Run a command after every tool call |
+| `--hook-session-start` | Run a command when a session starts |
+| `--hook-session-end` | Run a command when a session ends |
+| `--hook-on-user-input` | Run a command when waiting for input |
+
+All flags are repeatable — pass multiple to register multiple hooks.
+
+```bash
+# Add a session-start hook
+$ docker agent run agent.yaml --hook-session-start "./scripts/setup-env.sh"
+
+# Combine multiple hooks
+$ docker agent run agent.yaml \
+ --hook-pre-tool-use "./scripts/validate.sh" \
+ --hook-post-tool-use "./scripts/log.sh"
+
+# Add hooks to an agent from a registry
+$ docker agent run agentcatalog/coder \
+ --hook-pre-tool-use "./audit.sh"
+```
+
+
+
ℹ️ Merging behavior
+
+
CLI hooks are appended to any hooks already defined in the agent's YAML config. They don't replace existing hooks. Pre/post-tool-use hooks added via CLI match all tools (equivalent to matcher: "*").
+
+
diff --git a/docs/features/cli/index.md b/docs/features/cli/index.md
index ec4af5d52..d0915f9db 100644
--- a/docs/features/cli/index.md
+++ b/docs/features/cli/index.md
@@ -25,16 +25,21 @@ Launch the interactive TUI with an agent configuration.
$ docker agent run [config] [message...] [flags]
```
-| Flag | Description |
-| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
-| `-a, --agent <name>` | Run a specific agent from the config |
-| `--yolo` | Auto-approve all tool calls |
-| `--model <ref>` | Override model(s). Use `provider/model` for all agents, or `agent=provider/model` for specific agents. Comma-separate multiple overrides. |
-| `--session <id>` | Resume a previous session. Supports relative refs (`-1` = last, `-2` = second to last) |
-| `--prompt-file <path>` | Include file contents as additional system context (repeatable) |
-| `-d, --debug` | Enable debug logging |
-| `--log-file <path>` | Custom debug log location |
-| `-o, --otel` | Enable OpenTelemetry tracing |
+| Flag | Description |
+| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
+| `-a, --agent <name>` | Run a specific agent from the config |
+| `--yolo` | Auto-approve all tool calls |
+| `--model <ref>` | Override model(s). Use `provider/model` for all agents, or `agent=provider/model` for specific agents. Comma-separate multiple overrides. |
+| `--session <id>` | Resume a previous session. Supports relative refs (`-1` = last, `-2` = second to last) |
+| `--prompt-file <path>` | Include file contents as additional system context (repeatable) |
+| `--hook-pre-tool-use <cmd>` | Add a pre-tool-use hook command (repeatable). See [Hooks]({{ '/configuration/hooks/' | relative_url }}). |
+| `--hook-post-tool-use <cmd>` | Add a post-tool-use hook command (repeatable) |
+| `--hook-session-start <cmd>` | Add a session-start hook command (repeatable) |
+| `--hook-session-end <cmd>` | Add a session-end hook command (repeatable) |
+| `--hook-on-user-input <cmd>` | Add an on-user-input hook command (repeatable) |
+| `-d, --debug` | Enable debug logging |
+| `--log-file <path>` | Custom debug log location |
+| `-o, --otel` | Enable OpenTelemetry tracing |
```bash
# Examples
@@ -46,6 +51,10 @@ $ docker agent run agent.yaml --model "dev=openai/gpt-4o,reviewer=anthropic/clau
$ docker agent run agent.yaml --session -1 # resume last session
$ docker agent run agent.yaml --prompt-file ./context.md # include file as context
+# Add hooks from the command line
+$ docker agent run agent.yaml --hook-session-start "./scripts/setup-env.sh"
+$ docker agent run agent.yaml --hook-pre-tool-use "./scripts/validate.sh" --hook-post-tool-use "./scripts/log.sh"
+
# Queue multiple messages (processed in sequence)
$ docker agent run agent.yaml "question 1" "question 2" "question 3"
```
diff --git a/pkg/config/hooks.go b/pkg/config/hooks.go
new file mode 100644
index 000000000..a1ef127c1
--- /dev/null
+++ b/pkg/config/hooks.go
@@ -0,0 +1,96 @@
+package config
+
+import (
+ "strings"
+
+ "github.com/docker/docker-agent/pkg/config/latest"
+)
+
+// HooksFromCLI builds a HooksConfig from CLI flag values.
+// Each string is treated as a shell command to run.
+// Empty strings are silently skipped.
+func HooksFromCLI(preToolUse, postToolUse, sessionStart, sessionEnd, onUserInput []string) *latest.HooksConfig {
+ hooks := &latest.HooksConfig{}
+
+ if len(preToolUse) > 0 {
+ var defs []latest.HookDefinition
+ for _, cmd := range preToolUse {
+ if strings.TrimSpace(cmd) == "" {
+ continue
+ }
+ defs = append(defs, latest.HookDefinition{Type: "command", Command: cmd})
+ }
+ if len(defs) > 0 {
+ hooks.PreToolUse = []latest.HookMatcherConfig{{Hooks: defs}}
+ }
+ }
+
+ if len(postToolUse) > 0 {
+ var defs []latest.HookDefinition
+ for _, cmd := range postToolUse {
+ if strings.TrimSpace(cmd) == "" {
+ continue
+ }
+ defs = append(defs, latest.HookDefinition{Type: "command", Command: cmd})
+ }
+ if len(defs) > 0 {
+ hooks.PostToolUse = []latest.HookMatcherConfig{{Hooks: defs}}
+ }
+ }
+
+ for _, cmd := range sessionStart {
+ if strings.TrimSpace(cmd) != "" {
+ hooks.SessionStart = append(hooks.SessionStart, latest.HookDefinition{Type: "command", Command: cmd})
+ }
+ }
+ for _, cmd := range sessionEnd {
+ if strings.TrimSpace(cmd) != "" {
+ hooks.SessionEnd = append(hooks.SessionEnd, latest.HookDefinition{Type: "command", Command: cmd})
+ }
+ }
+ for _, cmd := range onUserInput {
+ if strings.TrimSpace(cmd) != "" {
+ hooks.OnUserInput = append(hooks.OnUserInput, latest.HookDefinition{Type: "command", Command: cmd})
+ }
+ }
+
+ if hooks.IsEmpty() {
+ return nil
+ }
+
+ return hooks
+}
+
+// MergeHooks merges CLI hooks into an existing HooksConfig.
+// CLI hooks are appended after any hooks already defined in the config.
+// When both are non-nil and non-empty, a new merged object is returned
+// without mutating either input.
+func MergeHooks(base, cli *latest.HooksConfig) *latest.HooksConfig {
+ if cli == nil || cli.IsEmpty() {
+ return base
+ }
+ if base == nil || base.IsEmpty() {
+ return cli
+ }
+
+ merged := &latest.HooksConfig{
+ PreToolUse: append(append([]latest.HookMatcherConfig{}, base.PreToolUse...), cli.PreToolUse...),
+ PostToolUse: append(append([]latest.HookMatcherConfig{}, base.PostToolUse...), cli.PostToolUse...),
+ SessionStart: append(append([]latest.HookDefinition{}, base.SessionStart...), cli.SessionStart...),
+ SessionEnd: append(append([]latest.HookDefinition{}, base.SessionEnd...), cli.SessionEnd...),
+ OnUserInput: append(append([]latest.HookDefinition{}, base.OnUserInput...), cli.OnUserInput...),
+ }
+ return merged
+}
+
+// CLIHooks returns a HooksConfig derived from the runtime config's CLI hook flags,
+// or nil if no hook flags were specified.
+func (runConfig *RuntimeConfig) CLIHooks() *latest.HooksConfig {
+ return HooksFromCLI(
+ runConfig.HookPreToolUse,
+ runConfig.HookPostToolUse,
+ runConfig.HookSessionStart,
+ runConfig.HookSessionEnd,
+ runConfig.HookOnUserInput,
+ )
+}
diff --git a/pkg/config/hooks_test.go b/pkg/config/hooks_test.go
new file mode 100644
index 000000000..1edcabfca
--- /dev/null
+++ b/pkg/config/hooks_test.go
@@ -0,0 +1,167 @@
+package config
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/docker/docker-agent/pkg/config/latest"
+)
+
+func TestHooksFromCLI_Empty(t *testing.T) {
+ hooks := HooksFromCLI(nil, nil, nil, nil, nil)
+ assert.Nil(t, hooks)
+}
+
+func TestHooksFromCLI_SkipsEmptyCommands(t *testing.T) {
+ // All empty/whitespace-only commands should be filtered out
+ hooks := HooksFromCLI([]string{""}, []string{" "}, []string{""}, []string{" \t"}, nil)
+ assert.Nil(t, hooks)
+}
+
+func TestHooksFromCLI_MixedEmptyAndValid(t *testing.T) {
+ hooks := HooksFromCLI([]string{"", "echo pre", " "}, nil, []string{"echo start", ""}, nil, nil)
+ require.NotNil(t, hooks)
+
+ require.Len(t, hooks.PreToolUse, 1)
+ require.Len(t, hooks.PreToolUse[0].Hooks, 1)
+ assert.Equal(t, "echo pre", hooks.PreToolUse[0].Hooks[0].Command)
+
+ require.Len(t, hooks.SessionStart, 1)
+ assert.Equal(t, "echo start", hooks.SessionStart[0].Command)
+}
+
+func TestHooksFromCLI_PreToolUse(t *testing.T) {
+ hooks := HooksFromCLI([]string{"echo pre1", "echo pre2"}, nil, nil, nil, nil)
+ require.NotNil(t, hooks)
+
+ require.Len(t, hooks.PreToolUse, 1)
+ require.Len(t, hooks.PreToolUse[0].Hooks, 2)
+ assert.Equal(t, "command", hooks.PreToolUse[0].Hooks[0].Type)
+ assert.Equal(t, "echo pre1", hooks.PreToolUse[0].Hooks[0].Command)
+ assert.Equal(t, "echo pre2", hooks.PreToolUse[0].Hooks[1].Command)
+ // Matcher is empty string, which matches all tools by default
+ assert.Empty(t, hooks.PreToolUse[0].Matcher)
+}
+
+func TestHooksFromCLI_AllTypes(t *testing.T) {
+ hooks := HooksFromCLI(
+ []string{"pre-cmd"},
+ []string{"post-cmd"},
+ []string{"start-cmd"},
+ []string{"end-cmd"},
+ []string{"input-cmd"},
+ )
+ require.NotNil(t, hooks)
+
+ assert.Len(t, hooks.PreToolUse, 1)
+ assert.Len(t, hooks.PostToolUse, 1)
+ assert.Len(t, hooks.SessionStart, 1)
+ assert.Len(t, hooks.SessionEnd, 1)
+ assert.Len(t, hooks.OnUserInput, 1)
+
+ assert.Equal(t, "pre-cmd", hooks.PreToolUse[0].Hooks[0].Command)
+ assert.Equal(t, "post-cmd", hooks.PostToolUse[0].Hooks[0].Command)
+ assert.Equal(t, "start-cmd", hooks.SessionStart[0].Command)
+ assert.Equal(t, "end-cmd", hooks.SessionEnd[0].Command)
+ assert.Equal(t, "input-cmd", hooks.OnUserInput[0].Command)
+}
+
+func TestMergeHooks_BothNil(t *testing.T) {
+ assert.Nil(t, MergeHooks(nil, nil))
+}
+
+func TestMergeHooks_CLINil(t *testing.T) {
+ base := &latest.HooksConfig{
+ SessionStart: []latest.HookDefinition{{Type: "command", Command: "echo base"}},
+ }
+ result := MergeHooks(base, nil)
+ assert.Equal(t, base, result)
+}
+
+func TestMergeHooks_BaseNil(t *testing.T) {
+ cli := &latest.HooksConfig{
+ SessionStart: []latest.HookDefinition{{Type: "command", Command: "echo cli"}},
+ }
+ result := MergeHooks(nil, cli)
+ assert.Equal(t, cli, result)
+}
+
+func TestMergeHooks_BothNonNil(t *testing.T) {
+ base := &latest.HooksConfig{
+ SessionStart: []latest.HookDefinition{{Type: "command", Command: "echo base"}},
+ PreToolUse: []latest.HookMatcherConfig{{
+ Matcher: "shell",
+ Hooks: []latest.HookDefinition{{Type: "command", Command: "echo base-pre"}},
+ }},
+ }
+ cli := &latest.HooksConfig{
+ SessionStart: []latest.HookDefinition{{Type: "command", Command: "echo cli"}},
+ PreToolUse: []latest.HookMatcherConfig{{
+ Hooks: []latest.HookDefinition{{Type: "command", Command: "echo cli-pre"}},
+ }},
+ }
+
+ result := MergeHooks(base, cli)
+ require.NotNil(t, result)
+
+ // Session start hooks should be merged
+ require.Len(t, result.SessionStart, 2)
+ assert.Equal(t, "echo base", result.SessionStart[0].Command)
+ assert.Equal(t, "echo cli", result.SessionStart[1].Command)
+
+ // Pre tool use matchers should be merged
+ require.Len(t, result.PreToolUse, 2)
+ assert.Equal(t, "shell", result.PreToolUse[0].Matcher)
+ assert.Equal(t, "echo base-pre", result.PreToolUse[0].Hooks[0].Command)
+ assert.Empty(t, result.PreToolUse[1].Matcher)
+ assert.Equal(t, "echo cli-pre", result.PreToolUse[1].Hooks[0].Command)
+}
+
+func TestMergeHooks_DoesNotMutateOriginals(t *testing.T) {
+ base := &latest.HooksConfig{
+ SessionStart: []latest.HookDefinition{{Type: "command", Command: "echo base"}},
+ }
+ cli := &latest.HooksConfig{
+ SessionStart: []latest.HookDefinition{{Type: "command", Command: "echo cli"}},
+ }
+
+ result := MergeHooks(base, cli)
+
+ // Originals should not be mutated
+ assert.Len(t, base.SessionStart, 1)
+ assert.Len(t, cli.SessionStart, 1)
+ assert.Len(t, result.SessionStart, 2)
+}
+
+func TestRuntimeConfig_CLIHooks(t *testing.T) {
+ rc := &RuntimeConfig{}
+ assert.Nil(t, rc.CLIHooks())
+
+ rc.HookSessionStart = []string{"echo start"}
+ hooks := rc.CLIHooks()
+ require.NotNil(t, hooks)
+ assert.Len(t, hooks.SessionStart, 1)
+ assert.Equal(t, "echo start", hooks.SessionStart[0].Command)
+}
+
+func TestRuntimeConfig_Clone_CopiesHooks(t *testing.T) {
+ rc := &RuntimeConfig{}
+ rc.HookPreToolUse = []string{"pre"}
+ rc.HookPostToolUse = []string{"post"}
+ rc.HookSessionStart = []string{"start"}
+ rc.HookSessionEnd = []string{"end"}
+ rc.HookOnUserInput = []string{"input"}
+
+ clone := rc.Clone()
+ assert.Equal(t, rc.HookPreToolUse, clone.HookPreToolUse)
+ assert.Equal(t, rc.HookPostToolUse, clone.HookPostToolUse)
+ assert.Equal(t, rc.HookSessionStart, clone.HookSessionStart)
+ assert.Equal(t, rc.HookSessionEnd, clone.HookSessionEnd)
+ assert.Equal(t, rc.HookOnUserInput, clone.HookOnUserInput)
+
+ // Mutating clone should not affect original
+ clone.HookPreToolUse[0] = "changed"
+ assert.Equal(t, "pre", rc.HookPreToolUse[0])
+}
diff --git a/pkg/config/runtime.go b/pkg/config/runtime.go
index b08ba31ba..ff3d3eb35 100644
--- a/pkg/config/runtime.go
+++ b/pkg/config/runtime.go
@@ -23,6 +23,13 @@ type Config struct {
DefaultModel *latest.ModelConfig
GlobalCodeMode bool
WorkingDir string
+
+ // Hook overrides from CLI flags
+ HookPreToolUse []string
+ HookPostToolUse []string
+ HookSessionStart []string
+ HookSessionEnd []string
+ HookOnUserInput []string
}
func (runConfig *RuntimeConfig) Clone() *RuntimeConfig {
@@ -31,6 +38,11 @@ func (runConfig *RuntimeConfig) Clone() *RuntimeConfig {
}
clone.EnvFiles = slices.Clone(runConfig.EnvFiles)
clone.DefaultModel = runConfig.DefaultModel.Clone()
+ clone.HookPreToolUse = slices.Clone(runConfig.HookPreToolUse)
+ clone.HookPostToolUse = slices.Clone(runConfig.HookPostToolUse)
+ clone.HookSessionStart = slices.Clone(runConfig.HookSessionStart)
+ clone.HookSessionEnd = slices.Clone(runConfig.HookSessionEnd)
+ clone.HookOnUserInput = slices.Clone(runConfig.HookOnUserInput)
return clone
}
diff --git a/pkg/teamloader/teamloader.go b/pkg/teamloader/teamloader.go
index c538e9066..d17845cf2 100644
--- a/pkg/teamloader/teamloader.go
+++ b/pkg/teamloader/teamloader.go
@@ -145,6 +145,8 @@ func LoadWithConfig(ctx context.Context, agentSource config.Source, runConfig *c
expander := js.NewJsExpander(env)
+ cliHooks := runConfig.CLIHooks()
+
for _, agentConfig := range cfg.Agents {
// Merge CLI prompt files with agent config prompt files, deduplicating
promptFiles := slices.Concat(agentConfig.AddPromptFiles, loadOpts.promptFiles)
@@ -171,7 +173,7 @@ func LoadWithConfig(ctx context.Context, agentSource config.Source, runConfig *c
agent.WithMaxConsecutiveToolCalls(agentConfig.MaxConsecutiveToolCalls),
agent.WithNumHistoryItems(agentConfig.NumHistoryItems),
agent.WithCommands(expander.ExpandCommands(ctx, agentConfig.Commands)),
- agent.WithHooks(agentConfig.Hooks),
+ agent.WithHooks(config.MergeHooks(agentConfig.Hooks, cliHooks)),
}
models, thinkingConfigured, err := getModelsForAgent(ctx, cfg, &agentConfig, autoModel, runConfig)