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

> An agent cannot infer that a journal in a closed period is untouchable, that every query is scoped to a branch, or that document numbers are gapless. Here is how I packaged those rules as a Claude Code plugin, and which mechanism carries which rule.

URL: https://www.matthewswong.com/en/blog/claude-code-plugin-erp-domain-toolkit/

- Author: Matthews Wong (https://www.matthewswong.com/en/about/)
- Published: 2026-09-08
- Updated: 2026-09-08
- Category: ERP

> **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.

## The rules are real, consequential, and written down nowhere

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.

## Match the rule to the mechanism by when the mechanism loads

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.

## A branch-scoping skill, loaded when the agent touches the module

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.

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.

## The closed-period rule must not wait to be retrieved

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.

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.

*Read the tags before the labels, because the tags are the argument. Only the bottom band was left for the model to work out on its own, and it is the only one that produced a correcting entry.*

## The hook is the only layer that fires whether the agent agrees or not

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.

Two 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.

## What the plugin actually contains

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.

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.

1. Run claude with the plugin-dir flag pointing at the directory. It loads the plugin for one session without installing it, and takes precedence over an installed copy of the same name, so you can test a change to something already deployed.
2. Edit a skill or a hook, then run the reload-plugins command to pick it up without restarting the session.
3. Trigger the event the hook matches, which here means asking for an edit under the numbering module, then read the debug log to confirm which hooks matched and how they exited. A hook that silently never matched looks exactly like a hook that passed.
4. Run claude plugin validate against the directory before sharing it. It prints Validation passed, or Validation passed with warnings, and the strict flag turns those warnings into errors.

*The third panel is why an ERP plugin is worth building at all. There is no re-run that undoes a posted journal, so the guard has to sit before the edit rather than in the review afterwards.*

## The rule I left to judgement, and what it cost

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.

- Say the invariant out loud before the task starts. If I cannot state it in one sentence, the agent has no chance of inferring it, and neither does the next engineer.
- Decide where it goes by asking what a violation costs: a re-render, a re-run, or a restatement. The third answer never goes in a skill.
- For anything in that third category, write the check as a script first and the prose second. The prose explains the script; the script is the part that actually holds.

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

- Claude Code documentation, Create plugins: manifest, structure, local testing with the plugin-dir flag and claude plugin validate
- Claude Code documentation, Plugins reference: plugin.json schema, component directories, hooks.json and the plugin root variables
- Claude Code documentation, Skills: SKILL.md frontmatter, the paths field, user-invocable, and what survives compaction
- Claude Code documentation, Hooks reference: the event list, PreToolUse input, exit codes and the permission decision fields
- Claude Code documentation, Memory: CLAUDE.md load order, path-scoped rules, and why a hook is the enforcement layer

## Source links

- https://code.claude.com/docs/en/plugins
- https://code.claude.com/docs/en/plugins-reference
- https://code.claude.com/docs/en/skills
- https://code.claude.com/docs/en/hooks
- https://code.claude.com/docs/en/memory

## FAQ

### Should an ERP business rule go in a Claude Code skill or in CLAUDE.md?

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.

### How do I stop a skill from loading 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.

### Can a Claude Code plugin ship a CLAUDE.md file with its rules?

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/.

### How does a PreToolUse hook block an edit to a protected ERP module?

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.

### Are hooks a guaranteed guardrail for financial code?

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.

---

- HTML version: https://www.matthewswong.com/en/blog/claude-code-plugin-erp-domain-toolkit/
- Bahasa Indonesia: https://www.matthewswong.com/id/blog/claude-code-plugin-erp-domain-toolkit/
- Site index for agents: https://www.matthewswong.com/llms.txt
- Sitemap: https://www.matthewswong.com/sitemap.xml
- API description (OpenAPI): https://www.matthewswong.com/openapi.json
