Skip to main content

Writing Hooks

This guide walks you through creating hooks for copilot-shell from scratch, from simple logging to complete workflow automation.

Prerequisites

  • copilot-shell installed and configured
  • Familiarity with Shell scripting, Python, or Node.js
  • Understanding of JSON format

Quick Start

Create a simple hook that logs all tool executions.

Key rule: Always write logs to stderr; only write the final JSON to stdout.

Step 1: Create the hook script

mkdir -p .copilot-shell/hooks
cat > .copilot-shell/hooks/log-tools.sh << 'EOF'
#!/usr/bin/env bash
input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name')
echo "Logging tool: $tool_name" >&2
echo "[$(date)] Tool executed: $tool_name" >> .copilot-shell/tool-log.txt
echo "{}"
EOF
chmod +x .copilot-shell/hooks/log-tools.sh

Step 2: Register in settings.json

{
"hooks": {
"enabled": true,
"PostToolUse": [
{
"hooks": [
{
"type": "command",
"command": ".copilot-shell/hooks/log-tools.sh",
"name": "tool-logger",
"timeout": 5000
}
]
}
]
}
}

Step 3: Run

After starting copilot-shell, every tool execution will be logged to .copilot-shell/tool-log.txt.


Practical Examples

Security: Block Writing Secrets to Files

Prevent writing files that contain API keys or passwords.

.copilot-shell/hooks/block-secrets.sh:

#!/usr/bin/env bash
input=$(cat)
content=$(echo "$input" | jq -r '.tool_input.content // .tool_input.new_string // ""')

if echo "$content" | grep -qE 'api[_-]?key|password|secret'; then
echo "Blocked potential secret" >&2
cat <<EOF
{
"decision": "deny",
"reason": "Security policy: potential secret content detected.",
"systemMessage": "Security scanner blocked this operation"
}
EOF
exit 0
fi

echo '{"decision": "allow"}'
exit 0

Configuration:

{
"hooks": {
"PreToolUse": [
{
"matcher": "write_file",
"hooks": [
{
"type": "command",
"command": ".copilot-shell/hooks/block-secrets.sh",
"name": "secret-scanner"
}
]
}
]
}
}

Dynamic Context Injection (Git History)

Add project context before each agent interaction.

.copilot-shell/hooks/inject-context.sh:

#!/usr/bin/env bash
context=$(git log -5 --oneline 2>/dev/null || echo "No git history")

cat <<EOF
{
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": "Recent commits:\n$context"
}
}
EOF

RAG Tool Filtering (BeforeToolSelection)

Use BeforeToolSelection to intelligently reduce the tool space.

.copilot-shell/hooks/filter-tools.js:

#!/usr/bin/env node
const fs = require('fs');

function main() {
const input = JSON.parse(fs.readFileSync(0, 'utf-8'));
const { llm_request } = input;
const messages = llm_request.messages || [];
const lastUserMessage = messages.slice().reverse().find(m => m.role === 'user');

if (!lastUserMessage) {
console.log(JSON.stringify({}));
return;
}

const text = lastUserMessage.content;
const allowed = ['write_todos'];

if (text.includes('read') || text.includes('check')) {
allowed.push('read_file', 'list_directory');
}
if (text.includes('test')) {
allowed.push('run_shell_command');
}

if (allowed.length > 1) {
console.log(JSON.stringify({
hookSpecificOutput: {
hookEventName: 'BeforeToolSelection',
toolConfig: { mode: 'ANY', allowedFunctionNames: allowed }
}
}));
} else {
console.log(JSON.stringify({}));
}
}

main();

Model Routing (BeforeModel)

Route requests to different models based on complexity.

.copilot-shell/hooks/model-router.py:

#!/usr/bin/env python3
import sys, json

input_data = json.load(sys.stdin)
llm_request = input_data.get("llm_request", {})
messages = llm_request.get("messages", [])

last_msg = messages[-1]["content"] if messages else ""
is_simple = len(last_msg) < 100 and not any(
kw in last_msg.lower() for kw in ["refactor", "architect", "design"]
)

if is_simple:
result = {
"hookSpecificOutput": {
"hookEventName": "BeforeModel",
"llm_request": {
"model": "qwen-turbo",
"config": {"temperature": 0.3},
},
}
}
else:
result = {}

print(json.dumps(result))

Synthetic Response (BeforeModel — Mock)

Skip the LLM call and return a predefined response directly.

.copilot-shell/hooks/mock-response.py:

#!/usr/bin/env python3
import sys, json

input_data = json.load(sys.stdin)
messages = input_data.get("llm_request", {}).get("messages", [])
last_msg = messages[-1]["content"] if messages else ""

if "ping" in last_msg.lower():
result = {
"decision": "deny",
"reason": "Synthetic response handled by BeforeModel hook",
"hookSpecificOutput": {
"hookEventName": "BeforeModel",
"llm_response": {
"text": "pong!",
"candidates": [{
"content": {"role": "model", "parts": ["pong!"]},
"finishReason": "STOP"
}],
"usageMetadata": {"totalTokenCount": 0}
}
}
}
print(json.dumps(result))
else:
print("{}")

Audit Trail (PostToolUse)

Use run_id to correlate all tool calls within a single agent run.

.copilot-shell/hooks/audit-trail.py:

#!/usr/bin/env python3
import sys, json, datetime

input_data = json.load(sys.stdin)

entry = {
"timestamp": datetime.datetime.now().isoformat(),
"session_id": input_data["session_id"],
"run_id": input_data.get("run_id"),
"event": input_data["hook_event_name"],
"tool": input_data.get("tool_name", ""),
}

with open(".copilot-shell/audit.jsonl", "a") as f:
f.write(json.dumps(entry) + "\n")

print("{}")

Query all operations for a specific run:

jq 'select(.run_id == "sess########3")' .copilot-shell/audit.jsonl

Writing Hooks in Different Languages

#!/usr/bin/env python3
import sys, json

def main():
try:
input_data = json.load(sys.stdin)
except (json.JSONDecodeError, EOFError):
print(json.dumps({}))
return

# Your logic here
print(json.dumps({"decision": "allow"}))

if __name__ == "__main__":
main()

Node.js

#!/usr/bin/env node
const fs = require('fs');

function main() {
const input = JSON.parse(fs.readFileSync(0, 'utf-8'));
// Your logic here
console.log(JSON.stringify({ decision: 'allow' }));
}

main();

Bash (Only for simple hooks)

#!/usr/bin/env bash
input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name // empty')
echo '{"decision": "allow"}'

Testing Hooks

Manually test by piping JSON directly to the script:

printf '{"hook_event_name":"PreToolUse","tool_name":"run_shell_command",
"tool_input":{"command":"rm -rf /tmp/test"}}' \
| python3 .copilot-shell/hooks/block-secrets.sh

Enable live tracing in a session by setting COPILOT_SHELL_DEBUG=1 to see hook invocations and their raw output in debug logs.

Writing hooks for copilot-shell

This guide walks you through creating hooks for copilot-shell, from simple logging to comprehensive workflow automation.

Prerequisites

  • copilot-shell installed and configured
  • Basic understanding of shell scripting, Python, or Node.js
  • Familiarity with JSON for hook input/output

Quick start

Let's create a simple hook that logs all tool executions.

Crucial Rule: Always write logs to stderr. Write only the final JSON to stdout.

Step 1: Create your hook script

mkdir -p .copilot-shell/hooks
cat > .copilot-shell/hooks/log-tools.sh << 'EOF'
#!/usr/bin/env bash
input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name')
echo "Logging tool: $tool_name" >&2
echo "[$(date)] Tool executed: $tool_name" >> .copilot-shell/tool-log.txt
echo "{}"
EOF
chmod +x .copilot-shell/hooks/log-tools.sh

Step 2: Register in settings.json

{
"hooks": {
"enabled": true,
"PostToolUse": [
{
"hooks": [
{
"type": "command",
"command": ".copilot-shell/hooks/log-tools.sh",
"name": "tool-logger",
"timeout": 5000
}
]
}
]
}
}

Step 3: Run copilot-shell

Now every tool execution will be logged to .copilot-shell/tool-log.txt.


Practical examples

Security: Block secrets in file writes

Prevent writing files containing API keys or passwords.

.copilot-shell/hooks/block-secrets.sh:

#!/usr/bin/env bash
input=$(cat)
content=$(echo "$input" | jq -r '.tool_input.content // .tool_input.new_string // ""')

if echo "$content" | grep -qE 'api[_-]?key|password|secret'; then
echo "Blocked potential secret" >&2
cat <<EOF
{
"decision": "deny",
"reason": "Security Policy: Potential secret detected in content.",
"systemMessage": "Security scanner blocked operation"
}
EOF
exit 0
fi

echo '{"decision": "allow"}'
exit 0

For PreToolUse, put any user-facing text in systemMessage — it is rendered as a per-hook notification box labeled with the hook name, independent of the final decision. Use reason for the denial/error message on deny or block outcomes; if systemMessage is omitted, reason is used as the fallback text for the notification.

Configuration:

{
"hooks": {
"PreToolUse": [
{
"matcher": "write_file",
"hooks": [
{
"type": "command",
"command": ".copilot-shell/hooks/block-secrets.sh",
"name": "secret-scanner"
}
]
}
]
}
}

Dynamic context injection (Git History)

Add relevant project context before each agent interaction.

.copilot-shell/hooks/inject-context.sh:

#!/usr/bin/env bash
context=$(git log -5 --oneline 2>/dev/null || echo "No git history")

cat <<EOF
{
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": "Recent commits:\n$context"
}
}
EOF

RAG-based Tool Filtering (BeforeToolSelection)

Use BeforeToolSelection to intelligently reduce the tool space.

.copilot-shell/hooks/filter-tools.js:

#!/usr/bin/env node
const fs = require('fs');

async function main() {
const input = JSON.parse(fs.readFileSync(0, 'utf-8'));
const { llm_request } = input;

const messages = llm_request.messages || [];
const lastUserMessage = messages
.slice()
.reverse()
.find((m) => m.role === 'user');

if (!lastUserMessage) {
console.log(JSON.stringify({}));
return;
}

const text = lastUserMessage.content;
const allowed = ['write_todos'];

if (text.includes('read') || text.includes('check')) {
allowed.push('read_file', 'list_directory');
}
if (text.includes('test')) {
allowed.push('run_shell_command');
}

if (allowed.length > 1) {
console.log(
JSON.stringify({
hookSpecificOutput: {
hookEventName: 'BeforeToolSelection',
toolConfig: {
mode: 'ANY',
allowedFunctionNames: allowed,
},
},
}),
);
} else {
console.log(JSON.stringify({}));
}
}

main().catch((err) => {
console.error(err);
process.exit(1);
});

Model Router (BeforeModel)

Route requests to different models based on complexity.

.copilot-shell/hooks/model-router.py:

#!/usr/bin/env python3
import sys, json

input_data = json.load(sys.stdin)
llm_request = input_data.get("llm_request", {})
messages = llm_request.get("messages", [])

# Check if the last message is simple
last_msg = messages[-1]["content"] if messages else ""
is_simple = len(last_msg) < 100 and not any(
kw in last_msg.lower() for kw in ["refactor", "architect", "design"]
)

if is_simple:
# Use a faster, cheaper model for simple queries
result = {
"hookSpecificOutput": {
"hookEventName": "BeforeModel",
"llm_request": {
"model": "qwen-turbo",
"config": {"temperature": 0.3},
},
}
}
else:
result = {}

print(json.dumps(result))

Synthetic Response (BeforeModel — Mock)

Skip the LLM call entirely and return a predefined response.

.copilot-shell/hooks/mock-response.py:

#!/usr/bin/env python3
import sys, json

input_data = json.load(sys.stdin)
llm_request = input_data.get("llm_request", {})
messages = llm_request.get("messages", [])

last_msg = messages[-1]["content"] if messages else ""

if "ping" in last_msg.lower():
result = {
"decision": "deny",
"reason": "Synthetic response handled by BeforeModel hook",
"hookSpecificOutput": {
"hookEventName": "BeforeModel",
"llm_response": {
"text": "pong!",
"candidates": [{
"content": {"role": "model", "parts": ["pong!"]},
"finishReason": "STOP"
}],
"usageMetadata": {"totalTokenCount": 0}
}
}
}
print(json.dumps(result))
else:
print("{}")

Audit Trail with run_id (PostToolUse)

Use run_id to correlate all tool calls within a single agent run for auditing.

.copilot-shell/hooks/audit-trail.py:

#!/usr/bin/env python3
import sys, json, datetime

input_data = json.load(sys.stdin)

entry = {
"timestamp": datetime.datetime.now().isoformat(),
"session_id": input_data["session_id"],
"run_id": input_data.get("run_id"),
"event": input_data["hook_event_name"],
"tool": input_data.get("tool_name", ""),
}

with open(".copilot-shell/audit.jsonl", "a") as f:
f.write(json.dumps(entry) + "\n")

print("{}")

Query all actions from a specific run:

jq 'select(.run_id == "sess########3")' .copilot-shell/audit.jsonl

Response Logger (AfterModel)

Log all LLM responses for auditing.

.copilot-shell/hooks/log-responses.py:

#!/usr/bin/env python3
import sys, json, datetime

input_data = json.load(sys.stdin)
llm_response = input_data.get("llm_response", {})
llm_request = input_data.get("llm_request", {})

entry = {
"timestamp": datetime.datetime.now().isoformat(),
"model": llm_request.get("model", "unknown"),
"response_text": llm_response.get("text", "")[:200],
"tokens": llm_response.get("usageMetadata", {}).get("totalTokenCount", 0)
}

with open(".copilot-shell/response-log.jsonl", "a") as f:
f.write(json.dumps(entry) + "\n")

# Observation only - return empty output
print("{}")

Writing hooks in different languages

#!/usr/bin/env python3
import sys, json

def main():
try:
input_data = json.load(sys.stdin)
except (json.JSONDecodeError, EOFError):
print(json.dumps({}))
return

# Your logic here
print(json.dumps({"decision": "allow"}))

if __name__ == "__main__":
main()

Node.js

#!/usr/bin/env node
const fs = require('fs');

function main() {
const input = JSON.parse(fs.readFileSync(0, 'utf-8'));
// Your logic here
console.log(JSON.stringify({ decision: 'allow' }));
}

main();

Bash (Simple hooks only)

#!/usr/bin/env bash
input=$(cat)
# Use jq for JSON parsing
tool_name=$(echo "$input" | jq -r '.tool_name // empty')
echo '{"decision": "allow"}'

Testing your hooks

You can test hooks manually by piping JSON directly to your script. For example, to test the built-in sandbox-guard.py hook with a dangerous command:

printf '{"hook_event_name":"PreToolUse","tool_name":"run_shell_command",
"tool_input":{"command":"rm -rf /tmp/test"}}' \
| python3 src/copilot-shell/hooks/sandbox-guard.py

For live tracing during a session, set COPILOT_SHELL_DEBUG=1 to see hook invocations and their raw outputs in the debug log.