diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index b2fe6d08ce99..aeac954662cc 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -70,6 +70,36 @@ const COMPACTION_THINKING_TEXT: &str = "goose is compacting the conversation..." const DEFAULT_FRONTEND_INSTRUCTIONS: &str = "The following tools are provided directly by the frontend and will be executed by the frontend when called."; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ToolCategory { + Shell, + Read, + Write, + Other, +} + +fn categorize_tool(tool_name: &str) -> ToolCategory { + let local = tool_name.rsplit("__").next().unwrap_or(tool_name); + match local { + "shell" | "bash" | "exec" | "run" => ToolCategory::Shell, + "read" | "view" | "cat" | "read_file" => ToolCategory::Read, + "write" | "edit" | "patch" | "write_file" | "edit_file" => ToolCategory::Write, + _ => ToolCategory::Other, + } +} + +fn extract_string_arg(input: &Value, keys: &[&str]) -> Option { + let obj = input.as_object()?; + for k in keys { + if let Some(s) = obj.get(*k).and_then(|v| v.as_str()) { + if !s.is_empty() { + return Some(s.to_string()); + } + } + } + None +} + /// Context needed for the reply function pub struct ReplyContext { pub conversation: Conversation, @@ -304,6 +334,65 @@ impl Agent { .await; } + async fn emit_pre_tool_extended_hooks( + &self, + tool_name: &str, + tool_input: Option<&Value>, + session: &Session, + ) { + let working_dir = session.working_dir.to_string_lossy().to_string(); + match categorize_tool(tool_name) { + ToolCategory::Shell => { + if let Some(cmd) = tool_input.and_then(|v| extract_string_arg(v, &["command"])) { + self.emit_with_matcher( + crate::hooks::HookEvent::BeforeShellExecution, + &session.id, + &cmd, + tool_name, + tool_input.cloned(), + &working_dir, + ) + .await; + } + } + ToolCategory::Read => { + if let Some(path) = + tool_input.and_then(|v| extract_string_arg(v, &["path", "file", "file_path"])) + { + self.emit_with_matcher( + crate::hooks::HookEvent::BeforeReadFile, + &session.id, + &path, + tool_name, + tool_input.cloned(), + &working_dir, + ) + .await; + } + } + ToolCategory::Write | ToolCategory::Other => {} + } + } + + async fn emit_with_matcher( + &self, + event: crate::hooks::HookEvent, + session_id: &str, + matcher_context: &str, + tool_name: &str, + tool_input: Option, + working_dir: &str, + ) { + if !self.hook_manager.has_hooks(event) { + return; + } + let mut ctx = crate::hooks::HookContext::new(event, session_id) + .with_tool(tool_name.to_string(), tool_input) + .with_working_dir(working_dir.to_string()); + ctx.matcher_context = Some(matcher_context.to_string()); + self.hook_manager.emit(event, ctx).await; + } + fn with_post_tool_hook( &self, result: ToolCallResult, @@ -318,6 +407,7 @@ impl Agent { .arguments .as_ref() .map(|a| serde_json::Value::Object(a.clone())); + let category = categorize_tool(&tool_name); let fut = async move { let processed_result = @@ -331,11 +421,38 @@ impl Agent { if hook_manager.has_hooks(event) { let ctx = crate::hooks::HookContext::new(event, &session_id) - .with_tool(tool_name, tool_input) - .with_working_dir(working_dir); + .with_tool(tool_name.clone(), tool_input.clone()) + .with_working_dir(working_dir.clone()); hook_manager.emit(event, ctx).await; } + if event == crate::hooks::HookEvent::PostToolUse { + let extended = match category { + ToolCategory::Shell => Some(( + crate::hooks::HookEvent::AfterShellExecution, + tool_input + .as_ref() + .and_then(|v| extract_string_arg(v, &["command"])), + )), + ToolCategory::Write => Some(( + crate::hooks::HookEvent::AfterFileEdit, + tool_input + .as_ref() + .and_then(|v| extract_string_arg(v, &["path", "file", "file_path"])), + )), + _ => None, + }; + if let Some((ext_event, Some(matcher))) = extended { + if hook_manager.has_hooks(ext_event) { + let mut ctx = crate::hooks::HookContext::new(ext_event, &session_id) + .with_tool(tool_name, tool_input) + .with_working_dir(working_dir); + ctx.matcher_context = Some(matcher); + hook_manager.emit(ext_event, ctx).await; + } + } + } + processed_result }; @@ -796,6 +913,17 @@ impl Agent { .await; } + let tool_input_for_extended = tool_call + .arguments + .as_ref() + .map(|a| serde_json::Value::Object(a.clone())); + self.emit_pre_tool_extended_hooks( + &tool_call.name, + tool_input_for_extended.as_ref(), + session, + ) + .await; + if tool_call.name == PLATFORM_MANAGE_SCHEDULE_TOOL_NAME { let arguments = tool_call .arguments @@ -2092,6 +2220,8 @@ impl Agent { if !last_assistant_text.is_empty() { tracing::info!(target: "goose::agents::agent", trace_output = last_assistant_text.as_str()); } + + self.emit_hook(crate::hooks::HookEvent::Stop, &session_config.id).await; }.instrument(reply_stream_span)); Ok(inner) } @@ -2754,4 +2884,34 @@ mod tests { Ok(()) } + + #[test] + fn categorize_tool_recognizes_conventional_names() { + assert_eq!(categorize_tool("developer__shell"), ToolCategory::Shell); + assert_eq!(categorize_tool("filesystem__write"), ToolCategory::Write); + assert_eq!(categorize_tool("filesystem__edit"), ToolCategory::Write); + assert_eq!(categorize_tool("filesystem__read"), ToolCategory::Read); + assert_eq!(categorize_tool("filesystem__view"), ToolCategory::Read); + assert_eq!(categorize_tool("filesystem__cat"), ToolCategory::Read); + assert_eq!(categorize_tool("scheduler__list"), ToolCategory::Other); + assert_eq!(categorize_tool("shell"), ToolCategory::Shell); + } + + #[test] + fn extract_string_arg_picks_first_present_key() { + let input = serde_json::json!({ "file_path": "/tmp/a.txt", "path": "/tmp/b.txt" }); + assert_eq!( + extract_string_arg(&input, &["path", "file", "file_path"]).as_deref(), + Some("/tmp/b.txt") + ); + let input = serde_json::json!({ "file_path": "/tmp/a.txt" }); + assert_eq!( + extract_string_arg(&input, &["path", "file", "file_path"]).as_deref(), + Some("/tmp/a.txt") + ); + let input = serde_json::json!({ "other": 1 }); + assert!(extract_string_arg(&input, &["path"]).is_none()); + let input = serde_json::json!({ "path": "" }); + assert!(extract_string_arg(&input, &["path"]).is_none()); + } } diff --git a/documentation/blog/2026-05-14-goose-hooks/index.md b/documentation/blog/2026-05-14-goose-hooks/index.md new file mode 100644 index 000000000000..8e75f2e1679d --- /dev/null +++ b/documentation/blog/2026-05-14-goose-hooks/index.md @@ -0,0 +1,208 @@ +--- +title: "Hooks: run your own scripts on every goose event" +description: "goose now supports lifecycle hooks via the Open Plugins spec. Wire shell scripts into PreToolUse, PostToolUse, UserPromptSubmit, SessionStart, and more." +image: /img/blog/goose-hooks.jpg +authors: + - alexhancock +--- + +![Hooks: run your own scripts on every goose event](/img/blog/goose-hooks.jpg) + +goose now supports **lifecycle hooks**. Drop a plugin into a directory on disk and goose will run your shell scripts when things happen during a session: a tool is about to fire, a tool just finished, the user submitted a prompt, the session started, the session ended. + +If you've used Claude Code's hooks or git hooks, it's the same idea. If you haven't: the agent loop is now scriptable from the outside, without writing any Rust or any MCP server. + + + +## How it works + +goose follows the [Open Plugins hooks specification](https://open-plugins.com/agent-builders/components/hooks). Any plugin directory under `~/.agents/plugins//` (user scope) or `/.agents/plugins//` (project scope) that contains a `hooks/hooks.json` file is auto-discovered at startup. + +A minimal hook config looks like this: + +```json +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "developer__shell|developer__text_editor", + "hooks": [ + { "type": "command", "command": "${PLUGIN_ROOT}/scripts/log.sh" } + ] + } + ] + } +} +``` + +When the event fires, goose runs the command, sets `PLUGIN_ROOT` in the environment, and pipes a JSON payload to the script on stdin: + +```json +{ + "event": "PostToolUse", + "session_id": "abc-123", + "tool_name": "developer__shell", + "tool_input": { "command": "rg TODO" }, + "working_dir": "/Users/you/project" +} +``` + +The supported events are: + +- `SessionStart`, `SessionEnd`, `Stop` +- `UserPromptSubmit` +- `PreToolUse`, `PostToolUse`, `PostToolUseFailure` +- `BeforeReadFile`, `AfterFileEdit` +- `BeforeShellExecution`, `AfterShellExecution` + +The `matcher` field is a regex tested against the most relevant string for the event (tool name, file path, or shell command). Leave it off and the hook fires for every event of that type. Hooks that fail or time out are logged but won't crash the host tool, so your scripts can be as scrappy as you want. + +## A few things to try + +### 1. Have goose talk to you when it actually needs you + +Pick a handful of events that mean "the human's attention would be useful right now" β€” a tool failed, the session wrapped, a long-running command finished β€” and have goose speak a line when one of them fires: + +```json +{ + "hooks": { + "PostToolUseFailure": [{ "hooks": [{ "type": "command", "command": "${PLUGIN_ROOT}/scripts/notify.sh" }] }], + "SessionEnd": [{ "hooks": [{ "type": "command", "command": "${PLUGIN_ROOT}/scripts/notify.sh" }] }], + "AfterShellExecution": [ + { + "matcher": "^(cargo (test|build|clippy)|pnpm (test|build)|just )", + "hooks": [{ "type": "command", "command": "${PLUGIN_ROOT}/scripts/notify.sh" }] + } + ] + } +} +``` + +Then `notify.sh` branches on the payload and picks a line: + +```bash +#!/usr/bin/env bash +payload="$(cat)" +event="$(printf '%s' "$payload" | jq -r .event)" + +case "$event" in + PostToolUseFailure) echo "That didn't work. Need a hand?" | say -v Daniel ;; + SessionEnd) echo "Done. Come check this out." | say -v Daniel ;; + AfterShellExecution) echo "Long command finished." | say -v Daniel ;; +esac +``` + +Tune the `matcher` regex to whatever counts as "long-running" in your world β€” test suites, builds, deploys, `terraform apply`. + +### 2. The "goose is doing something" desk light πŸͺΏπŸ’‘ + +If you have a smart bulb with an HTTP API (Hue, LIFX, Home Assistant, etc.), turn it on when goose starts a tool call and off when it finishes: + +```json +{ + "hooks": { + "PreToolUse": [{ "hooks": [{ "type": "command", "command": "curl -s -X POST http://hue.local/light/on" }] }], + "PostToolUse": [{ "hooks": [{ "type": "command", "command": "curl -s -X POST http://hue.local/light/off" }] }] + } +} +``` + +Now your desk lamp is a status indicator for the agent. Walk away, glance back, and if it's on, goose is still working. + +### 3. Auto-format every file goose edits + +Hook `AfterFileEdit` and run the formatter yourself so the agent doesn't have to remember: + +```json +{ + "hooks": { + "AfterFileEdit": [ + { + "matcher": "\\.(ts|tsx|js|jsx|json|md)$", + "hooks": [{ "type": "command", "command": "${PLUGIN_ROOT}/scripts/format.sh" }] + }, + { + "matcher": "\\.rs$", + "hooks": [{ "type": "command", "command": "cargo fmt" }] + } + ] + } +} +``` + +`scripts/format.sh` reads the file path from stdin and runs `prettier --write` against it. + +### 4. Daily session journal + +Hook `SessionEnd` and append a one-line summary to a markdown file: + +```bash +#!/usr/bin/env bash +payload="$(cat)" +session_id="$(printf '%s' "$payload" | jq -r .session_id)" +date_str="$(date '+%Y-%m-%d %H:%M')" +echo "- $date_str β€” session $session_id ended" >> ~/notes/goose-journal.md +``` + +Capture `UserPromptSubmit` payloads too and you've got a log of every question you asked your agent today. + +### 5. Make goose sound like a submarine + +Because you can: + +```json +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "afplay /System/Library/Sounds/Submarine.aiff" + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "say 'Captain, the session has ended.'" + } + ] + } + ] + } +} +``` + +Useful when goose is working on a long task in another window. The audio cue tells you it's actually doing things instead of sitting there waiting on you. + +## Try the example + +There's a working example in the repo at [`examples/plugins/hello-hooks`](https://github.com/block/goose/tree/main/examples/plugins/hello-hooks) β€” a plugin that wires up `SessionStart`, `UserPromptSubmit`, `PreToolUse`, and `PostToolUse` and prints a friendly emoji to stderr for each one. Copy it to `~/.agents/plugins/`, start a session, and watch the events fly by: + +```bash +mkdir -p ~/.agents/plugins +cp -R examples/plugins/hello-hooks ~/.agents/plugins/hello-hooks +chmod +x ~/.agents/plugins/hello-hooks/scripts/announce.sh + +goose session +# πŸš€ [hello-hooks] SessionStart +# πŸ’¬ [hello-hooks] UserPromptSubmit +# ⚑ [hello-hooks] PreToolUse tool=developer__shell +# βœ… [hello-hooks] PostToolUse tool=developer__shell +``` + +Every event also gets appended to `~/.agents/plugins/hello-hooks/last-event.log` so you can see the exact JSON your scripts receive. Fire some events, `tail` the log, build from there. + +## Why this matters + +MCP servers give goose new tools. Hooks go the other direction: they give you a way to react to what goose is doing, in real time, with whatever language you already know. Bash, Python, a Go binary, a one-line `curl`. It's all just a command on stdin. + +The plugin model is small on purpose: a folder, a JSON file, a script. No registration step, no daemon, no rebuild. Drop it in, start goose, it works. Take it out and goose doesn't notice it's gone. + +If you build something fun, share it. The `examples/plugins/` directory is a good home for community plugins, and the [Open Plugins spec](https://open-plugins.com) means anything you build here works with other agents that adopt it. + +Happy hooking. πŸͺ diff --git a/documentation/static/img/blog/goose-hooks.jpg b/documentation/static/img/blog/goose-hooks.jpg new file mode 100644 index 000000000000..57ef81c5ca31 Binary files /dev/null and b/documentation/static/img/blog/goose-hooks.jpg differ