# Hooks

> Executable scripts in ~/.config/shelley/hooks that rewrite prompts and messages, add slash commands, or react when a turn ends.

A hook is an executable file in `~/.config/shelley/hooks/`. Shelley runs it
at a fixed point in a conversation's life, feeds it text or JSON on stdin,
and, for most hooks, uses what it prints.

## The rules

- The file name is the hook name (`system-prompt`, `end-of-turn`, ...). Slash
  commands go in `hooks/slash/<command>`.
- It must be executable (`chmod +x`). Missing or non-executable files are
  ignored. Any language works; start the file with a `#!` line.
- The directory is always `$HOME/.config/shelley/hooks`; `XDG_CONFIG_HOME`
  doesn't move it.
- Hooks are looked up on every event, so there's nothing to restart.
- Each run has a 30-second timeout.
- Hooks run with Shelley's own environment and working directory, not the
  conversation's. Slash commands also get a few `SHELLEY_*` variables.
- If a hook fails (non-zero exit, timeout, unparseable output), the operation
  it belongs to is aborted and your message isn't recorded. A conversation
  that was being started can still be left behind, empty. `end-of-turn` is
  the exception, because by then there's nothing left to abort; its failures
  are only logged.
- Where a payload includes HTTP request headers, `Cookie`, `Set-Cookie`,
  `Authorization`, and `Proxy-Authorization` are removed first.

| Hook | Fires | Stdin | Stdout |
|---|---|---|---|
| `system-prompt` | whenever a system prompt is built | prompt text | replacement prompt (required, non-empty) |
| `new-conversation` | once, when a conversation is created (not from a draft; see below) | JSON | JSON with fields to change, or nothing |
| `chat-message` | on each message to an existing conversation, a draft's first included | JSON | `{"message": ...}`, or nothing |
| `slash/<command>` | when a message starts with `/<command>` | JSON | replacement message text, or nothing |
| `end-of-turn` | when a turn ends | JSON | ignored |

The examples below were tested against Shelley with `jq` installed. To see
exactly what a hook receives, start with a hook that runs
`tee /tmp/hook-input.json`: passing the input through unchanged is a no-op
for every hook except slash commands, which should use
`cat > /tmp/hook-input.json` instead.

## system-prompt

Runs every time Shelley builds a system prompt: when a conversation, a
subagent, or a `/btw` side question starts; after `/clear` or `/compact`; and
when a conversation's tools or system prompt are changed in its settings.
Stdin is the rendered prompt; it starts like this:

```text
You are Shelley, a coding agent. Communicate with brevity. Be persistent and creative.

Initial pwd: /home/you/project

Git root: /home/you/project
...
```

Subagent prompts start with `You are a subagent of Shelley, a coding agent.`
Stdout replaces the prompt and must not be empty.

```sh
#!/bin/sh
# Append house rules to every system prompt.
cat
printf '\nUse British spelling in prose and comments.\n'
```

## new-conversation

Runs once when a conversation is created with its first message, whether you
started it or it's a new subagent or `/btw` side question.

Drafts skip it. Once you pause typing in a new conversation, the web UI saves
the text as a draft conversation, and a draft's first message goes to
`chat-message` instead. So from the web UI this hook usually sees only
subagents and side questions; conversations started with
`shelley client chat` or `POST /api/conversations/new` get it.

```json
{
  "prompt": "hello shelley",
  "model": "predictable",
  "cwd": "",
  "readonly": {
    "conversation_id": "cH3UICU",
    "is_subagent": false,
    "headers": [
      ["Accept", "*/*"],
      ["Content-Length", "49"],
      ["Content-Type", "application/json"],
      ["User-Agent", "curl/8.5.0"],
      ["X-Custom", "demo"],
      ["X-Exedev-Email", "test@example.com"]
    ]
  }
}
```

For subagents and side questions, `readonly.is_subagent` is `true`,
`readonly.parent_id` is set, and `headers` is absent. Headers come as
`[name, value]` pairs sorted by name.

Print any of these to change them; empty or missing fields, and empty output,
change nothing:

```json
{ "prompt": "", "model": "", "cwd": "", "slug": "" }
```

`cwd` sets the working directory. `model` switches the model, falling back to
the original if Shelley can't use the one you name. `prompt` replaces the
first message. `slug` sets the conversation's URL slug instead of letting a
model generate one; it's sanitized, and ignored if it collides with an
existing one, and for subagents and side questions.

```sh
#!/bin/sh
# Conversations that would start in $HOME start in ~/scratch instead.
mkdir -p "$HOME/scratch"
jq -c --arg home "$HOME" \
  'if .cwd == $home or .cwd == "" then {cwd: ($home + "/scratch")} else {} end'
```

## chat-message

Runs when you send a message to an existing conversation: a follow-up, or the
first message of a draft. Otherwise a conversation's first message goes to
`new-conversation` instead, and messages a parent agent sends its subagents
never trigger it.

```json
{
  "message": "follow-up question",
  "readonly": {
    "conversation_id": "cH3UICU",
    "model": "predictable",
    "reasoning_level": "high",
    "queued": false,
    "headers": [
      ["Accept", "*/*"],
      ["Content-Length", "54"],
      ["Content-Type", "application/json"],
      ["User-Agent", "curl/8.5.0"],
      ["X-Exedev-Email", "test@example.com"]
    ]
  }
}
```

`reasoning_level` is the conversation's setting, or the model's default when
there's no override; it's empty only when the provider picks a default Shelley
can't know in advance. `queued` is true when the message will wait in the
queue rather than interrupt the current turn.

Print `{"message": "..."}` to replace the message. Empty output, an empty
`message`, or the same text changes nothing.

```sh
#!/bin/sh
# Expand a one-word follow-up into the full routine.
jq -c 'if .message == "ship it"
       then {message: "Run the tests, fix anything that fails, then commit."}
       else {} end'
```

## Slash commands

Your own slash commands. When a message starts with `/<command>`, where the
command matches `[a-zA-Z0-9_][a-zA-Z0-9_-]*`, Shelley looks for
`~/.config/shelley/hooks/slash/<command>`. If there's no such executable, the
message goes through untouched. Works for first messages and follow-ups.

```json
{
  "command": "review",
  "args": "error handling",
  "raw_message": "/review error handling",
  "conversation_id": "cLPLYL4",
  "is_new_conversation": false,
  "cwd": "/home/you/project",
  "model": "claude-sonnet-4.5",
  "user_email": "you@example.com"
}
```

The same context arrives as environment variables: `SHELLEY_SLASH_COMMAND`,
`SHELLEY_SLASH_ARGS`, `SHELLEY_CONVERSATION_ID`, `SHELLEY_CWD`,
`SHELLEY_MODEL`, and `SHELLEY_USER_EMAIL`.

Stdout becomes the message the model sees. Empty stdout keeps the original,
which suits hooks that only have side effects. A failure rejects the message
with a `400`, and the message isn't recorded.

```sh
#!/bin/sh
# /review [focus]: a canned code-review request.
printf 'Review the uncommitted changes here (git diff HEAD). '
printf 'Look for bugs, missing tests and unclear names. Do not edit files.\n'
if [ -n "$SHELLEY_SLASH_ARGS" ]; then
  printf 'Focus on: %s\n' "$SHELLEY_SLASH_ARGS"
fi
```

Mind the exit status: ending a script with `[ -n "$X" ] && printf ...` exits
1 when `$X` is empty, which counts as a failure.

Ordering: on a follow-up (or a draft's first message), the slash hook runs
first and `chat-message` sees its output. On a new conversation,
`new-conversation` runs first and the slash hook sees the prompt it returned.

Built-in commands win over hooks with the same name. The web UI handles
`/fork`, `/new`, `/archive`, `/rename`, `/diff`, `/shell`, `/compact`, and
`/clear` itself and never sends them, and in follow-ups the server handles
`/model`, `/tour`, and `/transcription` before any hook. The exception is
`/btw`: a `slash/btw` hook, if you have one, runs instead of the built-in.

## end-of-turn

Runs when the agent finishes a turn: the same signal that drives Shelley's
notifications. It doesn't run for subagents, for conversations with
notifications disabled, or while subagents or backgrounded commands the
conversation started are still running. Shelley doesn't wait for it, and
ignores its output.

```json
{
  "type": "end_of_turn",
  "conversation_id": "cMT7MTV",
  "timestamp": "2026-05-27T00:34:31.961478145Z",
  "hostname": "vm.exe.xyz",
  "model": "predictable",
  "conversation_url": "https://vm.exe.xyz/",
  "vm_name": "vm",
  "final_response": "Done."
}
```

`slug` is added once the conversation has one. `final_response` is the
agent's last text, or a summary of its last tool call. `hostname`,
`vm_name`, and `conversation_url` are built for exe.dev VMs and can be wrong
elsewhere: a host name without dots gets `.exe.xyz` appended, and the URL is
always `https`.

```sh
#!/bin/sh
# Desktop notification when a turn ends. Linux needs notify-send (libnotify).
msg=$(jq -r '(.slug // "shelley") + ": " + ((.final_response // "") | .[0:200])')
notify-send "Shelley" "$msg"
```

`notify-send` reaches your desktop only if Shelley's environment includes
the desktop session, as it does when you start Shelley from a terminal in
that session.

## See also

- The built-in `shelley-hooks` [skill](/docs/skills): ask Shelley to write a
  hook and it reads the same reference.
- [HOOKS.md](https://github.com/boldsoftware/shelley/blob/main/HOOKS.md) and
  the code in
  [server/system_prompt.go](https://github.com/boldsoftware/shelley/blob/main/server/system_prompt.go).
