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 inhooks/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_HOMEdoesn’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-turnis 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, andProxy-Authorizationare 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
- The built-in
shelley-hooksskill: ask Shelley to write a hook and it reads the same reference. - HOOKS.md and the code in server/system_prompt.go.