shelley

Customizing

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:

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.

#!/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.

{
  "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:

{ "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.

#!/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.

{
  "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.

#!/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.

{
  "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.

#!/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.

{
  "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.

#!/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