Plugins Are How You Ship a Claude Code Harness

Table of Contents

I built a tutor whose one job was to make me type the code myself.

Making that stick took four tries. Each one failed in a way the previous fix couldn’t see, and by the end I understood what a Claude Code plugin is actually for.

I wanted the tutor because I did not want to be a web monkey. Years of CRUD, which is fine and pays well, and I still couldn’t tell you what fork actually does. I wanted the stuff underneath: processes, signals, sockets.

You don’t get that from reading. I know, because I tried writing it as a book first. Twenty chapters, ch01-what-is-a-web-server.md through ch20-what-we-didnt-build.md, and it would have taught nobody anything, me included, because nowhere in twenty markdown files is the reader forced to type Process.wait and get it wrong. To learn code you have to put your hands on it.

What I did not want was to build a platform to hold a tutor. No web app, no accounts, no "learning environment" to install. That dies in week one, when the setup cost beats the curiosity.

Then it occurred to me that the tutor was already installed. My company gives Claude Code to every employee: every machine, authenticated, already in $PATH. That is not a tool sitting there. That’s a distribution channel.

So demonkey is a dojo where Claude Code teaches you to build Ruby web servers through the process family, from a raw TCP socket up to a Unicorn-like preforking server with heartbeats, graceful shutdown, and zero-downtime USR2 restart. It teaches, quizzes, and reviews. You type the code.

TL;DR: If your company already hands out Claude Code, you have a runtime on every machine and shipping a workflow costs one git clone. A plugin is the delivery format. It bundles the three layers your agent responds to: advice (CLAUDE.md), injection (a hook’s additionalContext), and enforcement (a PreToolUse deny), plus state on disk so the workflow survives compaction and closed laptops. The catch is that enforcement only reaches things a tool call exposes, so the last mile is your data and your evals. I built this for a teaching dojo, but it works for anything your team does the same way twice.

Diagram of the Claude Code harness as three stacked layers. Advice, from CLAUDE.md or the system prompt: authoritative and always in context, but it competes with other instructions. Injection, from a SessionStart hook as additionalContext: it arrives as a system-reminder and the model can reject it. Enforcement, from a PreToolUse hook returning permissionDecision deny: not advice at all, the tool call simply never happens. An arrow on the left marks advice as the weaker layer and enforcement as the stronger one. On the right, a robot at a keyboard is stopped by a brick wall and a red X, with a caption reading that the deny reason is your last prompt.

Here’s what it took to make it stay off the keyboard.

Try one: the rule, and the wall that makes it true

I’d already written about why prose alone doesn’t hold, in Your AI Skills Setup Is Probably Wrong. Short version: skills and CLAUDE.md are the same substance, markdown that becomes prompt, and the only thing separating them is whether the prompt reaches the model at all.

So demonkey never had a prose-only phase. The rule and its enforcement shipped in the same commit, on day one. Here’s the rule, from skills/tutor/SKILL.md:

## The one rule that defines this course

**The learner types the spine. You never write it.** The "spine" is the handful of
lines that *are* the lesson for the current step. You may:
- **explain** docs and APIs (cite the bundle file, never recall from the web),
- **generate glue** - only the files the step explicitly marks as `[glue]`,
- **review** the learner's spine by pointing at the exact line and naming the
  problem - *without rewriting it*.

A `PreToolUse` hook will block you from writing the current spine file. That is
intended. If you feel the urge to "just fix it," stop and ask a question instead.

Read that last paragraph again. The prose cites its own enforcement mechanism. The instruction isn’t asking for cooperation, it’s explaining a wall the model is about to hit. That framing matters: an unexplained denial produces an agent that retries the same thing three different ways.

The wall itself is fifteen lines of bash, from hooks/guard.sh:

spine)
  spine="$("$ROOT/bin/dojo.sh" spine 2>/dev/null || true)"
  # Nothing to protect for demo/no-spine steps.
  [[ -z "$spine" || "$spine" == "-" || "$spine" == */ ]] && exit 0
  fp="$(printf '%s' "$PAYLOAD" \
        | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' \
        | head -1 | sed -E 's/.*:[[:space:]]*"([^"]*)".*/\1/')"
  [[ -z "$fp" ]] && exit 0
  case "$fp" in
    *"$spine")
      deny "${spine} is the learner's spine for this step - the lines that ARE the
            lesson. Do not write or edit it. Instead: explain the relevant docs, let
            the learner type it, then review by pointing at specific lines. (You may
            write the glue files named in the step.)"
      ;;
  esac
  exit 0
  ;;

And deny is just this:

deny() {
  printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\n' "$1"
  exit 0
}

That’s the entire mechanism. Print JSON with permissionDecision: deny, exit 0, and the tool call does not happen. The model doesn’t get to weigh it against being helpful.

Sequence diagram across four lanes: the model, Claude Code, guard.sh, and dojo.sh. The model asks to write workspace/fork_echo.rb. PreToolUse fires and passes the payload to guard.sh on stdin. guard.sh asks dojo.sh what the current step's spine is and gets back workspace/fork_echo.rb. It returns permissionDecision deny along with a permissionDecisionReason. The write never happens, marked with a red X on the model's lane. A highlighted arrow carries the reason text back to the model as prompt, and the model explains the docs instead.

Two details worth stealing.

The deny reason is a prompt. permissionDecisionReason is the only text the model sees after a block, so it’s your last chance to steer. Notice the message doesn’t just refuse, it says what to do instead. A denial that says "forbidden" gets you retries. A denial that says "explain the docs, let the learner type it, then review specific lines" gets you a tutor.

The rule is dynamic because it reads state. The guard asks bin/dojo.sh spine what today’s lesson file is. The protected path changes as the learner advances, from the same fifteen lines. Here’s the whole curriculum it reads from, curriculum/steps.tsv:

1   Raw TCP echo server workspace/echo.rb   tcp
2   Rack app over a raw socket  workspace/rack_server.rb    http
3   Why one server is not enough    -   demo
4   Fork-per-connection workspace/fork_echo.rb  tcp
5   Preforking N workers    workspace/prefork.rb    tcp
6   Master process: signals and reaping workspace/master.rb tcp
7   Production-grade preforking workspace/unicorn_like.rb   http

Seven lines, four columns, and three different parts of the plugin read it: the guard takes column 3 to know what to lock, the title hook takes column 2 to name the session, the bench command takes column 4 to pick a harness. Step 3 has - for a spine, so the guard no-ops that day.

Everything is wired in hooks/hooks.json, which auto-loads:

{
  "hooks": {
    "SessionStart": [
      { "matcher": "startup|resume|clear|compact",
        "hooks": [ { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/session-start.sh" } ] }
    ],
    "PreToolUse": [
      { "matcher": "WebFetch|WebSearch",
        "hooks": [ { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/guard.sh web" } ] },
      { "matcher": "Bash",
        "hooks": [ { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/guard.sh bash" } ] },
      { "matcher": "Write|Edit",
        "hooks": [ { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/guard.sh spine" } ] }
    ]
  }
}

One gotcha that cost me an afternoon: do not add a hooks field to plugin.json. Hooks auto-load from hooks/hooks.json, and declaring both gives you a "Duplicate hooks file" error. There’s a test enforcing that now, because I broke it twice.

Try two: the tutor found the gap the hook couldn’t cover

I ran the pilot with a real learner. The hook held perfectly. The lesson still leaked.

The tutor never touched the spine file. It just read the code out loud, line by line, for the learner to transcribe. Letter obeyed, spirit destroyed. A PreToolUse hook bounds tool calls. It cannot bound prose.

Worse, when I went looking for the cause, the instruction that was supposed to protect the struggle was itself written as a dictation order:

4. **Type the spine** - tell the learner exactly what to type and where (the spine
   file, the approximate line count, the primitives to use), and which bundle docs
   to read first. Then **wait** for them to write it. Do not write it for them.

"Tell the learner exactly what to type." I wrote the bug myself. The fix:

4. **Type the spine** - set them up to WRITE it; do NOT dictate it. Give only: the
   file + its rough size, the GOAL (what it must do), the SHAPE at a high level
   (e.g. "an accept loop; per connection: parse -> build env -> call app -> write
   response -> close in `ensure`"), and which docs to read. Then **wait**. Do NOT
   enumerate the lines or hand a transcribe-this checklist - that's copying, not
   learning.

But the prompt wasn’t the only offender. The curriculum data was a recipe too. Step 4, before:

## Spine  (the learner types `workspace/fork_echo.rb`, ~8 lines)
Start from `workspace/echo.rb`. Type the fork block by hand:
- inside the accept loop, `pid = fork do ... end`,
- in the child: close the listening socket, run the echo, then `exit`,
- in the parent: close the accepted connection socket, then reap children so they
  don't become zombies - `Process.detach(pid)` *or* a non-blocking
  `Process.wait(-1, Process::WNOHANG)` sweep.

After:

## Spine  (the learner types `workspace/fork_echo.rb`, ~8 lines)
Start from `workspace/echo.rb`. The shape (the learner is new to `fork` - teach the
pieces, don't hand the code): for each accepted connection, `fork` a child that
handles it while the parent loops back to `accept`. Two things they must reason
through:
- after `fork`, the child and parent each hold both sockets - which does each keep,
  which does it close?
- a finished child becomes a **zombie** until the parent **reaps** it. What is
  reaping, and which `Process` call does it? (explain the options + the
  non-blocking-vs-blocking trade-off; let them pick and write it).

Same lesson, same gotchas, no transcript. The enforcement layer couldn’t reach this, because nothing here is a tool call. It’s a rewrite of the thing the model is reading.

Try three: the harness is bigger than the plugin

Next failure was not mine at all. It was the editor.

Claude Code’s input box suggests your next prompt. In a normal session that’s helpful. In a dojo it hands the learner the move they were supposed to derive. Greyed-out text that says "now add Process.detach(pid)" defeats the entire design.

You cannot block that with a hook, because hooks run too late. You cannot set it from plugin config, because a plugin’s bundled settings.json only supports a couple of keys and env isn’t one of them. The only place that can turn it off is the process environment, before claude starts. So this one lives outside the plugin, in a launcher script:

# Turn OFF Claude Code's input-box prompt suggestions for the dojo. The learner is
# meant to think and type the load-bearing code themselves; a grayed-out "type this"
# hand-out breaks that. The flag is read from the process env at startup, so it must
# be set HERE, before claude launches - a plugin's settings.json only supports the
# `agent`/`subagentStatusLine` keys (not `env`), and a hook runs too late to set it.
export CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false

exec claude \
  --plugin-dir "$PLUGIN_DIR" \
  "$@" \
  --disallowed-tools WebSearch WebFetch

This is the part people miss when they say "harness." Your plugin is one piece of it. The rest is the flags you launch with, the environment you launch in, and whatever bits of the tool you can only reach from outside. --disallowed-tools there is belt and suspenders: the guard hook already denies web access, but the CLI flag means the tools never even show up.

Try four: when you can’t trust the prompt, test it

The last failure was the most embarrassing, and the most useful.

The tutor quizzes you after each step. I’d written the checkpoints as finished questions in the step files, with the correct answer listed first and the distractors after, marked with a tidy checkmark:

### Concept check  (AskUserQuestion)
**Question:** the first `nc` echoes fine, and a second `nc` opened while the first is
still connected *hangs*. Why?
- ✅ **The process is busy in the read loop for client 1 and never returns to
  `accept`.** Confirm, then add: the 2nd connection isn't rejected - the kernel
  holds it in the *listen backlog*.
- ❌ "`accept` only returns once." -> Correct them: it's called in a loop.
- ❌ "The OS refuses the second connection." -> No, it's queued in the backlog.

The tutor recited them in order. Which meant the correct answer was option one in every checkpoint of every step. A learner could pass the whole course by pressing enter.

The fix was structural. Stop shipping questions, ship raw material the model has to compose from:

### Checkpoint - why the second client hangs
- **Anchor:** the first `nc` echoes fine; a second `nc` opened while the first is
  still connected hangs with no echo.
- **Land this concept:** the process is busy in the read loop for client 1 and never
  returns to `accept`. Nuance: the 2nd connection isn't rejected - the kernel holds
  it in the *listen backlog* until someone calls `accept` again.
- **Misconception - "`accept` only returns once":** it's called in a loop and
  returns a *new* socket each time; the problem is we don't *get back* to it.
- **Misconception - "the OS refuses the second connection":** no - it's queued in
  the backlog and served the instant we call `accept` again.
- *Fast-walker transfer:* what if you raised the `listen` backlog to 1000 - does the
  second client still hang? (Yes - one process still serves one at a time.)

There’s no order to recite anymore. The tutor has to build the question live and calibrate it to how that learner actually moved through the step.

Then the important part. "I told it to shuffle" is not evidence, so the next commit built an eval: boot the real tutor headless with claude --plugin-dir -p, drive it to the quiz beat as scripted learner profiles, and capture the AskUserQuestion tool call it composes via --output-format stream-json. No human answers anything; you inspect the generated options.

On step 1, the correct answer landed first 0 times out of 8 across both profiles, with distinct wordings, and the fast profile got harder transfer questions than the struggling one. Every run is stamped with the git SHA, so two versions of a prompt can be diffed on where the answer lands.

You can regression-test a prompt. That’s the layer above enforcement, and almost nobody builds it.

The invariants get unit tests too. From brk‘s suite, calling demonkey’s actual guard with a synthetic payload:

test('spine mode denies writing the current spine file, allows glue files', () => {
  const spine = spawnSync('bash', [path.join(PLUGIN_ROOT, 'bin', 'dojo.sh'), 'spine']).stdout.trim();

  const writeSpine = JSON.stringify({ tool_input: { file_path: spine } });
  assert.ok(isDeny(guard('spine', writeSpine).stdout), 'spine write should be denied');

  const writeGlue = JSON.stringify({ tool_input: { file_path: 'workspace/some_glue_helper.rb' } });
  assert.equal(guard('spine', writeGlue).stdout.trim(), '', 'glue write should be allowed');
});

What the plugin actually is

So: a rule with a wall behind it, then the data the rule reads, then the environment it all runs in, then an eval to prove it still holds. Four separate chores. Here’s what makes them one deliverable.

demonkey/
├── .claude-plugin/plugin.json   # manifest
├── skills/tutor/SKILL.md        # the Socratic loop
├── commands/                    # /start /next /hint /reveal /setup /status /bench
├── hooks/
│   ├── hooks.json               # event -> script
│   ├── guard.sh                 # the jail
│   ├── session-start.sh         # resume + inject the step
│   ├── title.sh                 # session title tracks the step
│   └── post-bench.sh            # scrape bench results into results.csv
├── bin/dojo.sh                  # state helper (progress.json + steps.tsv)
├── curriculum/                  # step-01..07.md, steps.tsv, reference/
└── demonkey.sh                  # launcher (env + flags)

The manifest is nine lines:

{
  "name": "demonkey",
  "description": "Tutored, constrained build of Ruby web servers through the PROCESS family only...",
  "version": "0.1.0",
  "author": { "name": "Antonio Barbosa" },
  "keywords": ["ruby", "processes", "fork", "preforking", "unicorn", "signals", "sockets"],
  "skills": "./skills/",
  "commands": ["./commands/"]
}

One of those files does something a CLAUDE.md structurally cannot: it runs before the learner types anything. session-start.sh bootstraps state and injects the current step.

printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"%s","sessionTitle":"%s"}}\n' "$ctx" "$title"

There’s no "remember to run /start." It also front-loads setup on a fresh project, which has to happen while the network is still available, before the jail closes:

if [[ "$setup_done" -eq 0 ]]; then
  setup_prefix="FIRST ACTION, before ANY tutoring: this project is not set up yet.
  Run the /demonkey:setup steps NOW - create workspace/, vendor the pinned gems,
  build the offline docs bundle. It is safe and idempotent and must run now while
  the network is available (before any offline jailing). === "
fi

Note this is additionalContext, the injection layer, and it’s the weakest of the three. The model treats it as a <system-reminder> rather than as your voice, and it can decide to ignore it. That’s fine here, because nothing load-bearing depends on it: the rules that must hold are in the guard, and the state is on disk. Use injection for orientation, never for guarantees.

How it knows where you were three weeks ago

This is the part that surprised me most, because I assumed it would be the hard bit and it turned out to be a text file.

The model remembers nothing. Context windows get compacted, sessions get /cleared, laptops get closed. So the harness keeps its memory outside the conversation entirely, in the project directory:

my-workshop/
├── .demonkey/
│   ├── progress.json      # where you are
│   ├── .setup_done        # sentinel: deps vendored, docs bundled
│   ├── .titled_step       # last step we renamed the session for
│   └── results.csv        # benchmark rows, accumulated across sessions
└── workspace/
    ├── echo.rb            # step 1, yours
    └── fork_echo.rb       # step 4, yours

progress.json is four fields:

{ "step": 5, "completed": [1, 2, 3, 4], "spine_file": "workspace/prefork.rb", "mode": "local-jailed" }

It lives in your project folder, not in ~/.claude. That’s deliberate, and it’s the single decision that makes the whole thing usable:

# State is PER-PROJECT (lives in the learner's project dir), NOT global - so a new
# folder starts fresh at Step 1 instead of inheriting another project's progress.
DATA_DIR="${CLAUDE_PROJECT_DIR:-$PWD}/.demonkey"

A new folder starts at step 1. You can run the dojo three times in three directories and they don’t know about each other. The state is scoped to the work, the way a .git directory is.

Reading and writing it is deliberately dumb. No jq, no dependencies, just sed and awk:

read_field() {  # read_field <key>
  sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\{0,1\}\([^\",}]*\)\"\{0,1\}.*/\1/p" "$PROGRESS" | head -1
}

tsv_col() {     # tsv_col <step> <colnum>
  awk -F '\t' -v s="$1" -v c="$2" '$1==s{print $c}' "$TSV"
}

advance)
  step="$(read_field step)"; comp="$(read_completed)"
  if [[ -z "$comp" ]]; then comp="$step"; else comp="$comp, $step"; fi
  next=$((step + 1))
  spine="$(tsv_col "$next" 3)"; [[ -z "$spine" || "$spine" == "-" ]] && spine="workspace/"
  write_progress "$next" "$comp" "$spine" "$(read_field mode)"
  echo "Advanced to step $next ($(tsv_col "$next" 2))" ;;

Now watch what happens when you come back after three weeks.

Timeline in three panels. Session A on day 1: progress.json holds step 4, the guard locks fork_echo.rb, and running slash next calls dojo.sh advance, which rewrites the file to step 5. Three weeks later: a closed laptop, and the model remembers nothing. Session B on day 22: the SessionStart hook reads progress.json, looks up row 5 of steps.tsv, and injects step 5 for the tutor. The guard now locks prefork.rb while fork_echo.rb is free again. Caption: nobody told it anything, the file is the memory.

You run claude in that folder. SessionStart fires before you type a character. session-start.sh reads progress.json, gets step: 5, looks up row 5 of steps.tsv to resolve the title and the curriculum file, and emits one JSON object with additionalContext telling the model: you are the tutor, the learner is on step 5, read curriculum/step-05.md, they type workspace/prefork.rb, don’t you dare write it. It sets the session title to demonkey - Step 5: Preforking N workers in the same breath.

The guard reconfigures itself. You didn’t tell it anything. guard.sh spine shells out to dojo.sh spine, which reads the same file, and now denies writes to workspace/prefork.rb instead of step 4’s fork_echo.rb. Your step-4 file is unlocked again, because it’s no longer the lesson.

Setup doesn’t re-run. The .setup_done sentinel is there, so the "run setup first" prefix never gets injected and you go straight back to tutoring.

You pass the step and run /next. The command is a gate before it’s a state change:

1. Confirm the current step's **success check** passed and the learner passed the
   **explain-it-back gate**. If not, do NOT advance - return to the tutor loop.
2. If earned, run `"${CLAUDE_PLUGIN_ROOT}/bin/dojo.sh" advance` to update progress.json.
3. Read the new step's curriculum file and begin its tutor loop.

advance bumps the number, appends to completed, and resolves the next spine. On your next prompt, title.sh notices the step changed (it caches the last one in .titled_step, so it’s a no-op on every other prompt) and renames the session.

That’s the whole memory model. A text file, an awk lookup, and a hook that reads both before the model wakes up. No database, nothing in the context window. And since it’s a plain file sitting in your project, you can cat it when you’ve forgotten where you were, or delete it and start the course over.

There’s one more piece worth showing, because it’s the same trick pointed at a different problem. Steps 4 and 5 run a benchmark that OOM-kills a fork-per-connection server, then re-runs it against a preforking one, and the comparison is the lesson. Asking the model to remember a number across two sessions would be silly, so a PostToolUse hook scrapes it out of the tool output instead:

# env/bench/run.sh emits its result as a single marker line:
#   PDOJO_RESULT,<server>,<budget>,<held>,<peak_rss_mb>,<oom>
# We grep the tool payload for that marker (robust through JSON escaping because the
# fields are comma-separated bare tokens) and append to results.csv. No-op otherwise.

The rows pile up in .demonkey/results.csv on their own. When step 5 wants to compare against step 4, the number is a file read, not a memory.

This is not about teaching Ruby

I built this for a dojo, but nothing about the machinery is educational. Strip out the curriculum and what’s left is a general shape: a workflow your team already performs informally, encoded so the agent performs it the same way every time.

Take the path from a ticket to merged code. Most teams have one, most of it lives in people’s heads, and every new hire learns it by getting it wrong in review. Written as a plugin it looks like this:

  • A skill per phase. /ticket:start pulls the issue, restates the acceptance criteria, and writes a plan. /ticket:pr generates the description in your template, with the ticket ID in the branch name because your CI needs it there.
  • A SessionStart hook that injects the current branch, the ticket it maps to, and whether the working tree is dirty. Orientation, not law.
  • A PreToolUse hook that denies git commit on main, or blocks edits to db/schema.rb when a migration hasn’t been generated, or refuses to touch a vendored directory. The rules that are currently a sentence in your CONTRIBUTING.md that everyone has scrolled past.
  • A PostToolUse hook that runs the formatter after every write, so the diff is never noisy.
  • State on disk for the same reason demonkey has it. Which ticket is this branch for, which review round we’re on, what the reviewer already flagged. It’ll still be there tomorrow.
  • An MCP server for Jira or Linear, shipped in the same plugin, so nobody configures it by hand.

That’s one directory, one git clone, one version number. Onboarding for how your team works becomes an install instead of a wiki page.

The dojo just made the requirements unusually strict. A tutor that writes your code is useless in a way a slightly-off commit message isn’t, so every gap showed up immediately. Your workflow will fail more quietly, which is worse.

Why this matters if your company already ships Claude Code

Writing that plugin is the easy half. Getting it onto twelve other laptops is where most internal tooling quietly dies.

A plugin is a directory with a manifest. To publish a catalog of them, you drop a .claude-plugin/marketplace.json in any git repo:

{
  "name": "brk",
  "owner": { "name": "Antonio Barbosa" },
  "plugins": [
    { "name": "demonkey", "source": "./dojos/demonkey", "category": "workshop" },
    { "name": "loopcraft", "source": "./dojos/loopcraft", "category": "workshop" }
  ]
}

That file is the whole registry. Anyone can then add it and install from it:

/plugin marketplace add geeksilva97/brk
/plugin install demonkey@brk

GitHub shorthand, any git URL, self-hosted GitLab, a local path, or a plain URL to a JSON file. No registry service, no publishing step, no account. Your existing private repo is a marketplace. brk install is a thin wrapper over exactly those two commands.

There’s also a way to enable plugins for a whole team by committing extraKnownMarketplaces and enabledPlugins into the project’s .claude/settings.json, so teammates get prompted to install on first load. I haven’t run that path myself, so I’ll point at the docs rather than hand you a config I’ve never shipped. One thing worth knowing if you go there: enabledPlugins is an object keyed by name@marketplace, not a list. Install one plugin by hand and read what Claude Code writes into your own ~/.claude/settings.json.

For anything ephemeral, --plugin-dir skips installation entirely:

claude --plugin-dir ./demonkey

Repeatable, takes .zip archives, and has a --plugin-url sibling that fetches one over HTTP. During development it’s the whole feedback loop: edit guard.sh, relaunch, see whether the deny fires. A local --plugin-dir plugin also shadows an installed one of the same name, so you can test a change against the real thing.

For a workshop, it means thirty people run one command, nobody’s global config is touched, and when it’s over they close the terminal and it’s gone.

On the left, a git repo containing .claude-plugin/marketplace.json. On the right, four laptops each badged with a green check and captioned: Claude Code already installed and authenticated. Two arrows run from the repo to the laptops. The first is slash plugin install demonkey at brk, persistent and available in every session. The second is claude --plugin-dir ./demonkey, ephemeral with nothing installed. Below, a dashed box containing the words server, signup, and runtime install is crossed out in red and captioned: none of this needed.

None of that involved a server, a signup, or asking anyone to install a runtime, because the runtime was already there. That’s the part I keep coming back to. The hard problem in internal tooling was never writing it.

One dojo, then a registry

demonkey was a pilot for a RubyConf talk. Once the pattern held, it generalized: brk now carries six dojos plus a generator, dojo-forge, that scaffolds a new one from a topic and ends with a validation gate running claude plugin validate, bash -n on every script, and a grep for leftover {{PLACEHOLDER}} markers.

The forge ships a numbered list of invariants every generated dojo must satisfy. It’s just my accumulated mistakes, written where they get checked instead of remembered. "No hooks field in the manifest" is invariant 1 because I broke it twice.

One decision I’d repeat. The generator was deliberately parked while I built the first dojos by hand, with a note in the commit: "extract a framework from validated dojos, don’t build it speculatively." I built the thing twice before abstracting it, and the abstraction is better for it.

Make the answer no

The last post ended on "skills are not broken, they’re just not for guidelines." I’ll extend it.

Guidelines aren’t broken either. They’re just not enforcement. If a rule matters enough that you’d revert a PR over it, it doesn’t belong in a markdown file where a model weighs it against being helpful. It belongs in a hook. And the hook belongs in a plugin, because that’s what turns a folder of your personal preferences into something your whole team runs.

demonkey’s README says it in four words, and I didn’t realize it was the thesis until I sat down to write this: hooks are the jail.

My tutor still wants to write my code. Every session, I get stuck on fork, and it reaches for the file. It just can’t reach it anymore, so it does the thing I actually built it for. It teaches.

Stop asking your agent nicely. Build the thing that makes the answer no.

Thanks for reading!


References

We want to work with you. Check out our Services page!