ERP Domain Rules as a Claude Code Plugin: Skills and Hooks

Photo by Panamitsu via Wikimedia Commons (CC BY-SA 4.0)
Decide by what a violation costs. A skill's description is in context every session but its body is only retrieved when the model chooses to load it, and after auto-compaction only the most recent invocations are re-attached within a shared token budget, so an older skill can be dropped entirely. Conventions like branch scoping are fine there. A rule whose failure mode is a correcting journal belongs in CLAUDE.md or an unscoped rules file, which loads at launch in every session.
Add a paths field to the SKILL.md frontmatter with glob patterns for the files the skill is about. Claude then loads the skill automatically only when working with matching files, using the same glob format as path-scoped rules. The description still sits in context every session so the model knows the skill exists, which is what you want: the page of detail arrives only when the agent opens the module it describes.
No. A plugin bundles skills, agents, hooks, MCP server definitions, LSP definitions and a limited settings file, but memory files are user, project or managed-policy scope, so CLAUDE.md is not one of a plugin's component directories. The practical workaround is to put the handful of always-loaded lines in the plugin's README and have each consuming repository commit them into its own CLAUDE.md or an unscoped file under .claude/rules/.
The hook receives the tool call as JSON on stdin, including tool_name and tool_input, so it can read the target file path and the proposed content. To block, either exit with code 2, which blocks the call and shows your stderr to the model, or exit 0 after printing a hookSpecificOutput object with permissionDecision set to deny and a permissionDecisionReason. The reason matters: it is what tells the agent how to fix the line instead of retrying it.
They are the most reliable of the three mechanisms but not unconditional. Hooks fire at fixed lifecycle events regardless of what the model concluded, which is why they carry anything mechanically checkable. But the documentation states that a timed-out command, http or mcp_tool hook does not block the tool call, which continues through the normal permission flow. Keep a guard to a grep and a regex, with no network calls, so a slow moment cannot turn your gate into a suggestion.

Photo by Panamitsu via Wikimedia Commons (CC BY-SA 4.0)
Key Takeaway
An AI agent cannot infer ERP invariants nobody wrote down. Package them as a Claude Code plugin and match each rule to a mechanism: a path-scoped skill for conventions, an always-loaded CLAUDE.md rule for anything that must survive compaction, and a PreToolUse hook for whatever a shell script can check deterministically.
The journal was dated 31 July. It was 6 August, July had been closed and reconciled four days earlier, and the agent had spent the previous hour adding a posting-date default that fell back to the document date whenever no period was supplied explicitly. The code was clean. The tests passed. The reviewer, which was me at speed on a Friday, read it as sensible. The new default path went through a repository method that never consults the period table, so the entry landed inside a closed month and finance found it in the next reconciliation.
The model was not the problem. The rule it broke exists only in the heads of the two people who built the posting module, and no amount of reading the repository class would have revealed it. This post is what I did afterwards: packaged the rules of our ERP domain as a Claude Code plugin, and got deliberate about which mechanism carries which rule. Every plugin, skill and hook behaviour below is from the Claude Code documentation, cited at the end. Every ERP rule is from a system I maintain.
An ERP codebase carries invariants that are not in the types, not asserted in the tests, and not visible in the file being edited. A journal in a closed period must not be touched. Every read is implicitly scoped to a branch. Document numbers are gapless. An approval hierarchy has a delegation path, and a delegated approval records two names, not one. Import freight is apportioned across the lines of a shipment on a specific basis that somebody chose years ago for a reason. None of that is inferable from a repository class and a DTO, so an agent working only from the code produces something that compiles, passes review by anyone equally unaware, and produces a financial correction next month.
This bites harder in ERP than in most domains for one reason: the consequence of being wrong is a stored record that other records already depend on. A wrong table sort is a re-render. A wrong valuation run is a re-run and a reconciliation. A wrong journal is a correcting entry, an audit note and a conversation with the finance team that opens with the word why. So the useful question is not how to teach the agent more. It is which rules can afford to be suggested, and which have to be enforced.
A Claude Code plugin is a versioned bundle: skills, subagents, hooks, MCP server definitions and LSP definitions, installed and toggled as one unit. Three of those can carry a domain rule, and they differ in the one respect that decides everything here, which is when their content is actually in front of the model, if it ever is.
| Mechanism | When its content reaches the model | So it should carry |
|---|---|---|
| A skill with path globs | Its description sits in context every session; the body loads only when the agent is working on a file matching the globs | Conventions, query shapes, the anatomy of a correct change |
| An unscoped rules file or CLAUDE.md | At launch in every session, and re-read from disk and re-injected after compaction | The two or three rules that must never depend on retrieval |
| A PreToolUse hook | On every matching tool call, whatever the model concluded | Anything a script can decide without judgement |
Read the middle column and most of the sorting does itself. A rule you would be annoyed to see ignored belongs in a skill. A rule you cannot afford to see ignored does not, because a skill's body is retrieved, and retrieval is a decision the model makes. A rule that is mechanically decidable should not be a sentence at all.
Branch scoping is the model case for a skill. It applies across a few hundred query sites, it is impossible to guess from a DTO, and the wrong version is a bug rather than a catastrophe: a report that shows one branch's numbers to another branch's manager, which we can catch in review. It is also verbose. The correct query shape, the reason the predicate lives in the repository rather than the controller, and the two endpoints that legitimately span branches together run to a page of prose I do not want occupying context in every session, including the sessions about CSS.
# erp-domain/skills/branch-scoping/SKILL.md
---
name: branch-scoping
description: How every read and write in this ERP is scoped to one branch, and which two endpoints may span branches. Use when editing repositories, queries or reports in the posting, inventory or reporting modules.
user-invocable: false
paths:
- "src/modules/posting/**/*.ts"
- "src/modules/inventory/**/*.ts"
- "src/modules/reporting/**/*.ts"
---
Every table carrying a branch_id column is scoped in the repository layer, never
in the controller. The predicate is not optional and it is not the caller's job.
// Wrong: the controller filters, so any caller that forgets sees every branch.
const rows = await this.ledgerRepo.find({ where: { periodId } });
return rows.filter((r) => r.branchId === user.branchId);
// Right: the repository takes the branch from the request context and there is
// no overload without it, so "forgetting" does not typecheck.
const rows = await this.ledgerRepo.findForBranch(ctx.branchId, { periodId });
Two endpoints legitimately span branches: the consolidated trial balance and the
group stock valuation. Both take an explicit branchIds array, both require the
group-finance role, and both are listed in scoping.allowlist.ts. If a third one
is ever needed, it goes in that file in the same commit, not in a comment.Two frontmatter fields do the work. The paths field limits automatic activation to files matching its globs, using the same glob format as path-scoped rules, so the skill arrives when the agent opens the posting or reporting module and stays out of the way otherwise. Setting user-invocable to false hides it from the slash menu, which is right for background knowledge: nobody wants to run branch scoping as a command. The description is in context every session either way, so the model knows the skill exists before it has read a single file.
My first version put the posting-period rule in that same skill. That was wrong, and the documentation says why. Invoked skill content is carried across auto-compaction on a budget: the most recent invocation of each skill is re-attached after the summary, keeping the first 5,000 tokens of each, with 25,000 tokens shared across all of them and the budget filled starting from the most recently invoked. Invoke several skills across a long session and the earliest one is dropped entirely. A rule whose failure mode is a correcting journal cannot live behind that arithmetic.
# .claude/rules/posting-periods.md
# No paths: frontmatter, so this loads at launch in every session instead of
# waiting to be retrieved. It is committed to the repository, not shipped in
# the plugin: a plugin cannot carry a CLAUDE.md or a rules file.
- A period has a state in accounting_period: OPEN, CLOSED or LOCKED. Read the
state. Never infer it from the calendar or from today's date.
- Never write a journal whose posting_date falls inside a CLOSED or LOCKED
period. There is no override flag, and adding one is not the fix.
- A correction to a closed month posts into the current OPEN period and carries
reverses_journal_id pointing at the original entry. That is the only route.
- postJournal() is the single entry point. A repository .save() that reaches
journal_entry without going through it is a bug even when its test passes.So the period rule went where loading is unconditional. CLAUDE.md is loaded at the start of every session, the project-root file is re-read from disk and re-injected after compaction, and a rules file with no paths field loads at launch with the same priority. Note what the plugin cannot do here. A plugin ships skills, agents, hooks, MCP and LSP definitions, but not a CLAUDE.md, because memory files are user, project or managed-policy scope. So the plugin's README carries these four lines and the consuming repository commits them, which is a slightly annoying split I have not found a way around.
When a rule is not being followed, find out whether it was ever loaded before you rewrite it. The InstructionsLoaded hook logs which instruction files loaded, when, and why, which is the quickest way to tell a badly worded rule apart from a path glob that never matched a file.

Gapless document numbering is the third kind of rule: mechanically checkable. Auditors read a gap in a numbered series as a deleted document, and the numbers on our tax invoices come out of a range the tax office allocated, so a skipped number is a question somebody has to answer. In code that reduces to a handful of patterns nobody wants near the numbering service, and the most common is a sequence read outside the transaction that consumes it. Asking a model to remember that is strictly worse than running a grep.
// erp-domain/hooks/hooks.json
// A plugin's hooks merge with your user and project hooks once it is enabled.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"if": "Edit(src/modules/numbering/**)",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/scripts/guard-numbering.sh",
"timeout": 10,
"statusMessage": "Checking document numbering..."
}
]
}
]
}
}#!/usr/bin/env bash
# erp-domain/scripts/guard-numbering.sh
# A PreToolUse hook receives its input as JSON on stdin.
set -euo pipefail
deny() {
jq -n --arg reason "$1" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: $reason
}
}'
exit 0 # exit 0 plus a deny decision. exit 2 also blocks, using stderr as
} # the message, but then the reason is harder to phrase carefully.
input=$(cat)
path=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')
body=$(printf '%s' "$input" | jq -r '.tool_input.new_string // .tool_input.content // empty')
case "$path" in
*"/modules/numbering/"*) ;;
*) exit 0 ;; # not ours: stay silent, do not slow the edit down
esac
# The sanctioned allocator locks the counter row inside the caller's own
# transaction. A bare nextval hands out a number that a rollback then loses,
# and the gap is what an auditor asks about six months later.
if printf '%s' "$body" | grep -q "nextval("; then
deny "Document numbers must be gapless. Use allocateDocumentNumber(tx, docType), which locks document_sequence FOR UPDATE inside the caller transaction. A bare nextval leaks a number on rollback. See skills/document-numbering in the erp-domain plugin."
fi
exit 0Two details are worth copying. The if filter uses permission-rule syntax, so the hook is scoped to the numbering module rather than to every edit in the repository, while the matcher still limits it to Write and Edit. And the guard returns a reason rather than just a refusal: exit code 2 blocks the call and shows stderr to the model, but emitting a deny decision on stdout lets you supply a permissionDecisionReason, which is the difference between an agent retrying the same line blindly and an agent fixing it.
A hook fires deterministically, but it does not block unconditionally. The documentation is explicit that a timed-out command, http or mcp_tool hook does not block the tool call: it continues through the normal permission flow. Keep a guard down to a grep and a regex, never give it a network call, and your gate will not quietly become a suggestion on the day something is slow.
The whole thing is a directory with a manifest, and nothing in it is generated. The one mistake worth naming ahead of time is putting the component directories inside the .claude-plugin folder: only plugin.json belongs there, and skills, hooks, agents and scripts all sit at the plugin root. The documentation calls this out as the common mistake, which is how I know I am not the only one who did it.
erp-domain/
├── .claude-plugin/
│ └── plugin.json # only this file belongs in here
├── skills/
│ ├── branch-scoping/SKILL.md # paths: posting, inventory, reporting
│ ├── approval-delegation/SKILL.md # paths: approval module
│ ├── document-numbering/SKILL.md # paths: numbering module
│ └── landed-cost/SKILL.md # added after the correction below
├── hooks/
│ └── hooks.json # PreToolUse guards
├── scripts/
│ ├── guard-numbering.sh
│ └── guard-closed-period.sh
└── README.md # the rules file a consumer must commit
// erp-domain/.claude-plugin/plugin.json
{
"name": "erp-domain",
"description": "Domain rules for the ERP: posting periods, branch scoping, document numbering, approval delegation, landed cost",
"version": "1.0.0",
"author": { "name": "Matthews Wong" }
}The manifest strictly needs only a name; everything else is discovered in the default locations, and version decides when installed copies pick up your changes. Development runs entirely off the filesystem, in four steps that never touch a marketplace.

Landed cost. An import shipment arrives carrying freight, insurance and duty, and those shipment-level charges have to be apportioned across its lines before any line has a unit cost. The defensible apportionment keys are share of line value, share of weight and share of volume, and for this shipment class our rule is share of FOB value. The agent apportioned by unit count, which is the obvious reading of spread the freight across the lines and is what anybody would write if nobody had told them otherwise. On a shipment of two thousand cheap fittings and forty expensive valves, the fittings absorbed most of the freight, and both unit costs came out wrong in opposite directions. Nothing failed, no test went red, and a margin report was wrong at month end. The fix was a revaluation and a correcting entry.
That rule had never been written down anywhere, because it was obvious to the two of us who implemented the module. Which is the shape of every rule in this post: obvious to the people who hold it, invisible to everyone else, and the plugin is simply the place we finally wrote them down. The apportionment basis is now three lines in a landed-cost skill plus a hook that refuses any change to the apportionment function without a matching fixture test. Before an agent goes near a costing or posting module now, I run three checks.
The plugin did not make the agent smarter. It moved three decisions out of the model's judgement and into files with names, and left a fourth in judgement until that cost me a revaluation. The rule I would carry into any domain where a mistake is stored rather than displayed is this: sort each rule by what a violation costs, put the cheap ones in a skill that loads on a glob, the expensive ones where loading is unconditional, and the checkable ones in a hook. Anything still resting on the model's judgement is a correction you have not paid for yet.
Sources