Articles
Jan 4, 2026 - 10 MIN READ
Building a Claude Code Plugin with Hooks

Building a Claude Code Plugin with Hooks

A practical guide to Claude Code's plugin system - hooks, skills, and commands. I built Claude Overflow to learn how it all fits together.

Peter Oliha

Peter Oliha

Claude Code has a plugin system that lets you extend its behavior. Hooks run shell commands at specific points in a session, skills give Claude new capabilities, and commands let users trigger actions directly. The documentation covers each piece, but I wanted to see how they work together.

So I built Claude Overflow - a plugin that turns your AI conversations into a personal StackOverflow-style knowledge base. Every technical Q&A gets saved as a markdown file and served in a searchable web UI.

Is it practical? Debatable. But it uses every major plugin feature, so it's a good way to learn the system.

Plugin Structure

A Claude Code plugin lives in a directory with a specific structure:

claude-overflow/
└── plugin/
    ├── .claude-plugin/
    │   └── plugin.json          # Plugin manifest
    ├── hooks/
    │   └── hooks.json           # Lifecycle hooks
    ├── commands/
    │   └── overflow.md          # Slash command
    ├── skills/
    │   └── overflow/
    │       └── SKILL.md         # Model-invoked skill
    └── scripts/
        ├── setup.sh             # Called by SessionStart
        └── cleanup.sh           # Called by SessionEnd

The .claude-plugin/plugin.json is the manifest:

{
  "name": "claude-overflow",
  "description": "StackOverflow-style Q&A archiving",
  "version": "2.0.0"
}

You load the plugin with:

claude --plugin-dir /path/to/claude-overflow/plugin

Hooks: Running Code at Lifecycle Events

Hooks let you run shell commands when specific events occur. Claude Code supports three hook types:

  • SessionStart - Runs once when a session begins
  • UserPromptSubmit - Runs every time the user sends a message
  • SessionEnd - Runs when the session closes

Here's how Claude Overflow uses all three in hooks/hooks.json:

{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/setup.sh\"",
            "timeout": 120
          }
        ]
      }
    ],
    "UserPromptSubmit": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "echo '{\"hookSpecificOutput\":{\"hookEventName\":\"UserPromptSubmit\",\"additionalContext\":\"For technical questions, use the overflow skill.\"}}'"
          }
        ]
      }
    ],
    "SessionEnd": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/cleanup.sh\"",
            "timeout": 10
          }
        ]
      }
    ]
  }
}

The ${CLAUDE_PLUGIN_ROOT} variable points to your plugin directory.

SessionStart: Setting Up the Environment

When a session starts, I need to:

  1. Create a temp directory for this session
  2. Copy the Nuxt template
  3. Install dependencies
  4. Start the dev server
  5. Tell Claude where to save files

The setup script handles this:

#!/bin/bash
set -e

# Parse session_id from stdin JSON
INPUT=$(cat)
SESSION_ID=$(echo "$INPUT" | grep -o '"session_id"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')

SESSION_ID="${SESSION_ID:-$$}"
OVERFLOW_DIR="/tmp/claude-overflow-${SESSION_ID}"
PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(dirname "$(dirname "$0")")}"
PORT=4242

# Find available port
while ! is_port_available $PORT; do
    PORT=$((PORT + 1))
done

# Create directory and copy template
mkdir -p "$OVERFLOW_DIR"
cp -r "$PLUGIN_ROOT/template/"* "$OVERFLOW_DIR/"
mkdir -p "$OVERFLOW_DIR/content/answers"

# Install and start
cd "$OVERFLOW_DIR"
pnpm install --silent
nohup pnpm dev > /tmp/claude-overflow-${SESSION_ID}.log 2>&1 &
sleep 3

# Output config for Claude's context
cat << EOF
{
  "hookSpecificOutput": {
    "hookEventName": "SessionStart",
    "additionalContext": "[CLAUDE OVERFLOW CONFIG] Content path: ${OVERFLOW_DIR}/content/answers | Server URL: http://localhost:${PORT}"
  }
}
EOF

The key is the JSON output at the end. The hookSpecificOutput.additionalContext gets injected into Claude's context, so it knows where to write files.

UserPromptSubmit: Injecting Reminders

Every time the user sends a message, I inject a reminder:

{
  "hookSpecificOutput": {
    "hookEventName": "UserPromptSubmit",
    "additionalContext": "For technical questions, use the overflow skill."
  }
}

This nudges Claude to use the skill for technical Q&A. Without it, Claude answers normally but doesn't save anything.

SessionEnd: Cleanup

When the session ends, kill the dev server:

#!/bin/bash
pkill -f "/tmp/claude-overflow-.*/node_modules" 2>/dev/null || true
echo '{"message": "Claude Overflow server stopped"}'

Each session is isolated in its own temp directory, so cleanup is straightforward.

Skills: Giving Claude New Capabilities

Skills are markdown files that define capabilities Claude can invoke. They're different from commands - skills are for Claude to use programmatically, commands are for users to trigger directly.

Here's the overflow skill in skills/overflow/SKILL.md:

---
name: overflow
description: Write technical Q&A responses as markdown files to Claude Overflow.
---

# Claude Overflow Workflow

Write your response as a markdown file to the Claude Overflow content directory.

## Step 1: Get Config from Context

Look for the `[CLAUDE OVERFLOW CONFIG]` message from session start. It contains:

- **Content path**: Where to write markdown files
- **Server URL**: Where answers are viewable

## Step 2: Generate File Details

- **slug**: URL-friendly version of the question (lowercase, hyphens, max 50 chars)
- **author**: Random dev username (e.g., `code_ninja_42`, `vim_wizard`)
- **votes**: Random number 5-200

## Step 3: Write the File

Write to: `{content_path}/{slug}.md`

Format:
\```markdown

---

title: "The user's question here"
description: "Brief summary"
slug: "the-slug"
createdAt: "2024-01-15T10:30:00Z"
author: "random_username"
votes: 42
tags:

- javascript
- async
  comments:
- username: "another_dev"
  content: "Great explanation!"
  timestamp: "2024-01-15T11:00:00Z"

---

Your complete answer here with code examples.
\```

## Step 4: Confirm

After writing, tell the user:

> Answer archived! View at {server_url}/answers/{slug}

The skill tells Claude exactly what to do: read the config from context, generate metadata, write a markdown file with specific frontmatter, and confirm to the user.

Claude uses its native Write tool to create the file - no MCP server needed.

Commands: User-Triggered Actions

Commands let users explicitly trigger skills with /command. Here's commands/overflow.md:

---
name: overflow
description: Answer a technical question and save it to Claude Overflow
arguments:
  - name: question
    description: The technical question to answer
    required: true
---

Answer the user's technical question and save it to the Claude Overflow archive.

## Steps

1. Get config from the `[CLAUDE OVERFLOW CONFIG]` context message
2. Generate a URL-friendly slug from the question
3. Write a markdown file with the answer
4. Tell the user where to view it

## Question

$ARGUMENTS

Now users can explicitly save answers:

/overflow What is the difference between map and filter in JavaScript?

The Web UI: Nuxt Content

The template is a simple Nuxt 4 app with Nuxt Content. When Claude writes a markdown file to the content directory, Nuxt Content picks it up immediately via hot reload.

The questions list page queries all answers:

<script setup lang="ts">
const { data: answers } = await useAsyncData('answers', () =>
  queryCollection('answers').order('createdAt', 'DESC').all()
);
</script>

<template>
  <div class="space-y-4">
    <NuxtLink
      v-for="answer in answers"
      :key="answer.slug"
      :to="`/answers/${answer.slug}`"
      class="block p-4 border rounded hover:bg-gray-50"
    >
      <h2 class="text-lg font-medium">{{ answer.title }}</h2>
      <div class="text-sm text-gray-500">
        {{ answer.author }} · {{ answer.votes }} votes
      </div>
    </NuxtLink>
  </div>
</template>

The detail page renders the markdown content with generated comments:

<script setup lang="ts">
const route = useRoute();
const { data: answer } = await useAsyncData(`answer-${route.params.slug}`, () =>
  queryCollection('answers').path(`/answers/${route.params.slug}`).first()
);
</script>

<template>
  <article v-if="answer">
    <h1>{{ answer.title }}</h1>
    <div class="meta">{{ answer.author }} · {{ answer.votes }} votes</div>
    <ContentRenderer :value="answer" />

    <div class="comments" v-if="answer.comments?.length">
      <div v-for="comment in answer.comments" :key="comment.timestamp">
        <strong>{{ comment.username }}</strong
        >: {{ comment.content }}
      </div>
    </div>
  </article>
</template>

The UI looks like a simplified StackOverflow - questions list, answer detail with votes and comments.

How It All Fits Together

Here's the flow when you ask a technical question:

  1. SessionStart hook runs setup.sh
    • Creates /tmp/claude-overflow-{session}/
    • Copies Nuxt template
    • Starts dev server on port 4242
    • Injects config into Claude's context
  2. UserPromptSubmit hook injects the reminder about the overflow skill
  3. Claude sees the reminder and decides to use the skill for technical questions
  4. The skill executes
    • Claude reads the config from its context
    • Generates slug, author, votes, comments
    • Uses the Write tool to create a markdown file
    • Tells the user the URL
  5. Nuxt Content hot-reloads and the answer appears in the web UI
  6. SessionEnd hook runs cleanup.sh to stop the server

What I Learned

Hooks are powerful but limited to shell commands. You can't run arbitrary code - everything goes through bash. For complex logic, write a script and call it.

The hookSpecificOutput JSON is how you communicate with Claude. Whatever you put in additionalContext gets added to Claude's context. This is how SessionStart passes config that skills use later.

Skills vs Commands: Skills are for Claude to invoke programmatically. Commands are for users to trigger explicitly. You often want both pointing to the same capability.

No MCP server needed for file operations. Claude has a native Write tool. Skills can just tell Claude what to write and where.

Session isolation matters. Each session gets its own temp directory and server port. This prevents conflicts when running multiple sessions.

Ways to Extend

The current implementation is session-scoped - each session gets its own temp directory and server, and everything disappears when the session ends. But there are some interesting directions to take this:

System-wide server - Instead of spinning up a new server per session, run a single persistent server that all sessions write to. Every Claude conversation across your machine contributes to the same knowledge base.

Persistence mode - Add a flag to save content to a permanent directory instead of /tmp. Your Q&A archive survives across sessions and machine restarts.

Custom directory - Let users specify where content gets saved. Point it at a git repo and you've got version-controlled knowledge.

Team knowledge base - This is where it gets interesting. An org could run a shared Claude Overflow instance. Every developer's AI conversations contribute to a collective knowledge base. Junior devs browse answers from senior devs' sessions. Onboarding gets easier because common questions are already answered.

The plugin system is flexible enough to support all of these. The SessionStart hook just needs different logic for where to write and whether to start a server.

Try It

The full code is on GitHub: claude-overflow

Clone it, run claude --plugin-dir /path/to/plugin, and ask some technical questions. The answers accumulate in a local knowledge base you can browse.

Most of us "figured things out" by copying from StackOverflow. You'd find an answer, read it, understand it, then write it yourself. That process taught you something.

With AI doing everything now, that step gets skipped. This brings it back - a knowledge base you can browse, copy from, and actually learn from.

Copyright © 2026