Skills, commands and hooks: how to structure a Claude Code plugin for a team

A Claude Code plugin bundles skills, commands and hooks. How to structure one, what each piece does and why critical rules belong in a hook, not a skill.

Cover for Skills, commands and hooks: how to structure a Claude Code plugin for a team

A hook that runs the linter every time the agent edits a file fits in this JSON (simplified example):

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write|MultiEdit",
        "hooks": [
          {
            "type": "command",
            "command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/lint-on-edit.sh\"",
            "timeout": 60
          }
        ]
      }
    ]
  }
}

Every time the agent edits or creates a file, Claude Code runs a script from the plugin. If the linter finds errors, the script returns them to the agent, which sees them before carrying on with the task. Nobody has to remember to ask for it.

In my team we use an internal plugin with skills and commands. I cannot publish its contents, so in this article the hook and command examples are generic and marked as such, and I show the structure of a plugin with a different one: the plugin for the Illuminate skill we have built at eLearning Media, which packages a skill for generating Blackboard Data / Illuminate SQL. What I do explain in detail is why each piece goes where it goes.

What the script behind the hook does

The hook only declares when something runs. The logic lives in the script, and a script like this involves more decisions than it seems. A minimal version, as an example:

#!/usr/bin/env bash
# Example: lint after every edit to a Kotlin file.
set -uo pipefail

# Claude Code passes a JSON on stdin with the tool used and its parameters.
FILE="$(jq -r '.tool_input.file_path // empty')"

# Only act on Kotlin; any other file exits without doing anything.
case "$FILE" in
  *.kt|*.kts) ;;
  *) exit 0 ;;
esac

# Find the nearest gradlew; if there is none, the hook does not apply in this repo.
dir="$(dirname "$FILE")"
while [ "$dir" != "/" ] && [ ! -x "$dir/gradlew" ]; do dir="$(dirname "$dir")"; done
[ -x "$dir/gradlew" ] || exit 0

if ! OUT="$(cd "$dir" && ./gradlew -q ktlintCheck 2>&1)"; then
  echo "The linter failed. Fix these errors:" >&2
  printf '%s\n' "$OUT" | tail -60 >&2
  exit 2
fi
exit 0

Those lines contain three decisions.

The script reads the edited file from standard input, where Claude Code passes it the tool used and its parameters. That way it can act only on what just changed.

It knows when to step aside. If the file is not Kotlin, or there is no gradlew on the way up to the root, it exits with 0 and does nothing. A team plugin is used in repos in different languages and the hook fires in all of them after every edit; if it could not step aside, it would get in the way outside Gradle projects.

It chooses what the agent sees through the exit code. In a PostToolUse hook, exit code 2 makes Claude Code pass whatever the script wrote to stderr on to the model. The edit has already happened, so the hook does not block it, but the agent receives the errors with an explicit instruction to fix them. With exit 0 it receives nothing and keeps working. It is a principle I find sound for any automated check: success is silent and only failures speak.

Why a hook and not a rule in the skill

Style conventions and architecture rules can be written in a skill, and in my team’s plugin they are: which quality tools are used, in what order and how to fix each type of failure.

The problem with a rule written in a skill is that it depends on the agent loading it and remembering to apply it at the right moment. A skill is triggered by its description, and activation is probabilistic (I go into this in how to create a skill that works). Even when it is loaded, nothing forces the agent to run the linter after every change; it may decide to do it at the end, or not at all.

A hook removes that dependency. It runs on an event in the agent’s lifecycle, every time, and what it returns enters the context whether the model wants it or not. It is the difference between asking someone to run the linter and having it in a pre-commit. The skill is still needed, because it explains how to fix what the hook detects, but the trigger no longer depends on it.

This is what people have started calling harness engineering: instead of trusting the model to remember the rules, you build an environment around the agent that checks its work and sends the failures back. The hook is the part of that environment that runs on its own.

How a plugin is organized

A plugin is a folder with a manifest and its pieces in folders with well-known names. The Illuminate one is built for Codex and is minimal:

blackboard-illuminate-sql/
├── .codex-plugin/plugin.json
├── assets/icon.svg
└── skills/
    └── illuminate-sql-support/
        ├── SKILL.md
        ├── agents/openai.yaml
        └── references/
            ├── reference-guide.md
            ├── database-structure.md
            ├── valid-fields.txt
            ├── illuminate-relations.csv
            ├── sql-examples.md
            └── tool-assignment-types-stage.md

The manifest, trimmed:

{
  "name": "blackboard-illuminate-sql",
  "version": "1.0.2",
  "description": "Create, validate, explain, and debug schema-safe Snowflake SQL for Blackboard Data / Illuminate.",
  "author": { "name": "eLearning Media", "url": "https://www.elearningmedia.es" },
  "skills": "./skills/",
  "interface": {
    "displayName": "Illuminate SQL Assistant",
    "shortDescription": "Schema-safe Snowflake SQL",
    "capabilities": ["SQL generation", "SQL validation", "SQL debugging", "Query explanation"],
    "defaultPrompt": [
      "Create an Illuminate SQL query for my reporting goal.",
      "Validate this Illuminate query against the current schema.",
      "Explain and fix this Snowflake SQL error."
    ]
  }
}

The functional part is three fields: name, version and where the skills are. The interface block is presentation: how the plugin appears in the tool, with what long description, which capabilities it advertises and which prompts it suggests to whoever installs it. The skill also has its own agents/openai.yaml, with a display name and a default prompt that invokes it explicitly with $illuminate-sql-support.

The interesting part is what does not change. The skills/illuminate-sql-support/ folder is a skill in the open Agent Skills standard: a SKILL.md with name and description plus its references. For Claude Code, the manifest would go in .claude-plugin/plugin.json and the skill folder could stay the same. What gets adapted to each tool is the wrapper, not the knowledge.

A Claude Code plugin for a team adds two folders that the Illuminate one does not have (example structure):

team-plugin/
├── .claude-plugin/plugin.json   name, version, description
├── skills/<name>/SKILL.md       + references/ and scripts/ depending on the skill
├── commands/<name>.md           one file per command
├── hooks/hooks.json             hooks like the one at the start
└── scripts/                     what the hooks and commands run

Skills are discovered automatically from the folder, without declaring them in the manifest.

The difference between the two plugins comes down to who they are for. The Illuminate plugin is meant for someone asking for an SQL query in a conversation: it only needs knowledge, rules and limits, and all of that fits in a skill. A team plugin is used while the agent modifies repositories, and there the procedures with steps that cannot be skipped also matter, along with checks that cannot depend on the model’s memory.

Three pieces, three triggers

Each piece is triggered differently, and that is what decides where each thing goes:

PieceWho triggers itWhat goes thereExample
SkillThe agent, when the description matchesKnowledge: rules, schemas, proceduresGenerating valid Illuminate SQL
CommandA person, typing /nameA procedure with an order and checkpointsRunning the full quality gate
HookClaude Code, on an eventChecks that cannot depend on anyoneLint after every edit

In my team’s plugin, commands always lean on a skill: the command sets the order and the moments where it has to stop, and the skill holds the detail of each step. That way the procedure is not written twice.

Commands: order and checkpoints

A command is a Markdown file with frontmatter that becomes a prompt when someone invokes it. One of the patterns we use is a quality gate in a loop. A generic version, as an example:

---
description: Run the full quality gate and fix whatever fails
argument-hint: "[module-path]"
allowed-tools: Bash, Read, Edit, Grep
---

Run the full gate on `$1` (or the current directory): lint, tests
(including architecture rules) and coverage verification.

If something fails, fix it and run the gate again. Do not finish until
everything passes.

Do not change thresholds or rules to pass the gate. If a rule seems
wrong to you, say so and stop.

At the end, summarize what was failing, what you fixed and the status of each gate.

The line that matters most is the one about thresholds. An agent asked to get a gate to green has two paths, and one of them is lowering the coverage threshold or disabling the failing rule. It is the shortest route to the literal goal, so it has to be forbidden explicitly.

The command and the hook share a tool, but not a moment. The hook runs after every edit and can only do the cheap part, like style, with a short timeout. The full gate, with tests and coverage, runs before a change is considered finished or a PR is opened. A simple way to organize it is a single script with two modes, a quick one for the hook and a full one for the command.

allowed-tools declares which tools the command can use. In the example, the ones needed to run the gate and fix existing files, without Write.

The other pattern we use is commands for procedures with destructive steps, such as a deployment. There the command acts mostly as a brake: it asks for missing values instead of assuming them, shows each step that writes to a sensitive environment and waits for explicit confirmation for that step, and forbids deletions without approval. The accompanying skill holds the technical detail; the command holds the points where a person has to say yes. The longest case of this kind, a phased migration of a legacy project, has its own article: modernizing legacy code with AI.

The plugin is not copied by hand onto each machine: we distribute it through a department marketplace, and that is where we keep adding new skills and commands as needs come up. How to keep that growth from ending in skills that step on each other is part of what I cover in the article on skill evals.

What I would take care of when building a hook like this

The script at the start is deliberately minimal. If I were taking it into a team plugin, there are three things I would solve first.

The first is distinguishing between “the code has errors” and “the tool is not configured”. If the repo has gradlew but does not apply the linter plugin, the task does not exist and Gradle fails. A script that treats that failure the same as a style error asks the agent to fix something that cannot be fixed within the code, and the agent will probably try. Checking first that the task exists, or filtering out that specific error, avoids it.

The second is silence when the gate does not run. A hook that steps aside with exit 0 tells the agent nothing, and that is correct in a JavaScript repo. But in a deliberate run, like the quality gate command, I would rather the script said loudly that it has not checked anything, instead of letting the final summary look like a success.

The third is that not every rule in a skill can become a hook, and they need to be separated. “Controllers do not access repositories directly” can be checked with an architecture rule; “choose names that explain intent” cannot be checked with anything. The former should end up in a hook or a gate; the latter will keep depending on the skill and on human review.

None of the three changes the underlying idea, which still seems right to me: knowledge goes in skills, procedures with checkpoints go in commands, and whatever cannot depend on the agent’s memory goes in hooks. Where the knowledge that ends up in skills comes from I explain in how to distill a team’s knowledge into skills, and the full map of the topic is in the agent skills guide.

OshyTech

Backend and data engineering focused on scalable systems, automation, and AI.

Navigation

Copyright 2026 OshyTech. All Rights Reserved