Skip to main content
BlogERP

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.

Matthews Wong

Software Engineer

September 202612 min read
ERP Domain Rules as a Claude Code Plugin: Skills and Hooks

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.

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.

MechanismWhen its content reaches the modelSo it should carry
A skill with path globsIts description sits in context every session; the body loads only when the agent is working on a file matching the globsConventions, query shapes, the anatomy of a correct change
An unscoped rules file or CLAUDE.mdAt launch in every session, and re-read from disk and re-injected after compactionThe two or three rules that must never depend on retrieval
A PreToolUse hookOn every matching tool call, whatever the model concludedAnything 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.

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

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.

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

Five stacked bands sorting ERP rules onto Claude Code mechanisms, from branch scoping loaded on a glob match, through an always-loaded posting-period rule and a numbering hook, down to landed cost apportionment left to the model's judgement.
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.

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

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.

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.

  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.
Three side-by-side panels comparing what a wrong change costs in screens and reports, in stock and costing, and in the ledger and tax modules, ending on a restatement.
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.

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

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.