Most published Claude Code harnesses are JS/TS-shaped. This one is for an Apex monorepo with three regions, an Azure pipeline, and a tax integration that doesn’t forgive.
The scar that started it
sf project deploy start --target-org acme-Prod --source-dir force-app
I almost ran that on a Friday afternoon. I’d been heads-down on a release branch, told Claude to deploy it to UAT, and watched it generate the command with the Prod alias instead. One character of muscle memory away from a production deploy with no dry-run and no test level. The Prod alias autocompletes ahead of the UAT one because P sorts before U.
I cancelled. Stared at the terminal for a minute. Got up, made tea.
There’s a class of Salesforce mistakes that don’t show up in any unit test or lint rule. They live one space character away from a normal command. The CLI doesn’t care which org you typed. Claude doesn’t either. Something has to.
So I stopped relying on attention and started building a harness.
What follows is the actual thing, including the parts I’d uninstall if I were honest about it. Hooks, the CLAUDE.md design, skills, agents, and MCP servers.
The harness, at a glance
Hooks run dozens of times. The context layer loads once. Skills wake up when something matches. MCP is only there when the model actually reaches for it.
I’ll go through them CLAUDE.md first, because that’s the piece I think is the most original. Hooks and validators second. Agents and MCP last. Then the things that broke.
CLAUDE.md as a context contract
CLAUDE.md gets auto-loaded into every session. Every token in it is paid for at the first response of every conversation, forever. So the rule I landed on, after a few iterations: one always-loaded CLAUDE.md per scope, and nothing else.
The project file at <repo>/.claude/CLAUDE.md is 75 lines. Eight sections. What the project is. A snapshot of record types and the main LWC. Session-start rules — always ask for --target-org, never assume. Repository map. A formatting note. Windows dev notes. Non-negotiables. Known gotchas. Primary trigger entry points. And a deep-dive doc index that points into .claude/doc/.
The index is the load-bearing part. Under .claude/doc/ there’s one markdown per topic. Booking flow architecture. API integration map. Trigger and flow execution order, including the recursion guards we rely on. Regional payment logic for AU/NZ/US, including AvaTax. SF CLI plugins reference. Debugging gotchas. The Summer ‘26 readiness audit.
None of those files are loaded by default. The project CLAUDE.md just lists them by path with a one-line description:
- Booking wizard steps / component wiring → .claude/doc/booking-flow-architecture.md
- API callouts, integration endpoints, payloads → .claude/doc/api-integration-map.md
- Triggers, flows, recursion guards → .claude/doc/trigger-flow-execution.md
- AU/NZ/US regional logic, tax, payments → .claude/doc/regional-payment-logic.md
Claude scans the index. When the topic comes up it opens the right file. The model handles that on its own without me telling it to load the deep-dive, which is the part I had to stop second-guessing.
The shape is the same one Anthropic recommends for skills: a short description that decides whether to pull in the body. I’m using it for project knowledge. CLAUDE.md is the skill description; the doc is the skill body.
The “non-negotiables” section earns its always-loaded slot. Every entry on it is a scar. One actual line, copied verbatim:
The main booking controller has dozens of
@AuraEnabledmethods — wide blast radius on changes.
That’s not a guideline. In the real file it names the class and carries the exact count, because a number I sat down and verified is worth more to a session than a vague warning. The whole list is twelve lines, and every entry is a scar like that one.
The repository map is the other piece that earns its slot. A small table mapping force-app/main/default/, templates/, deploy/, ui-tests/, SFDMU/, .husky/ to one-line purposes. Claude knows where to look without me re-explaining the layout.
Everything else lives behind a pointer.
Team note: the project .claude/ directory is committed. It’s shared with the rest of the Salesforce team. The user-level ~/.claude/ is just mine. The split matters and bites me later in the post.
Skills and the validator-dispatcher
There’s a sf-apex skill at ~/.claude/skills/sf-apex/. Same for sf-lwc, sf-flow, sf-soql, sf-debug, sf-deploy, sf-data, sf-metadata, sf-permissions, sf-integration, sf-connected-apps, plus the Agentforce family. Each one carries a scoring rubric — 150 points for Apex, 140 for LWC, 110 for flow, 100 for SOQL — best-practices guidance, and for the bigger ones an agentic fix loop that patches and re-scores when it finds a CRITICAL.
The rubric isn’t the interesting bit. Style guides are a dime a dozen.
The interesting bit is how it gets applied: automatically, via the PostToolUse dispatcher.
validator-dispatcher.py watches every Write and Edit. It looks at the extension and routes. .cls and .trigger go to the Apex syntax check first, then the 150-point rubric, then Salesforce Code Analyzer (PMD plus SFGE). .flow-meta.xml runs through flow scoring plus Flow Scanner. .js and .html under lwc/ go to the LWC validator. .soql files go to a query-plan analyzer.
| FILE PATTERN | ROUTED TO | RUBRIC |
|---|---|---|
| .cls · .trigger | Apex syntax → sf-apex → PMD + SFGE | 150 pt |
| .flow-meta.xml | sf-flow → Flow Scanner | 110 pt |
| lwc/*.js · *.html | sf-lwc validator | 140 pt |
| .soql | query-plan analyzer | 100 pt |
Write and Edit hits this table.The output comes back into the conversation as additional context on the next turn. If I write an Apex method that touches a CRUD-protected SObject without checking access, the next message has the rubric finding inline. I don’t have to ask “did you check access?” Claude doesn’t have to remember. The dispatcher does.
That mechanism is what changed the day-to-day. The blocking hooks stop catastrophes. The validators raise the floor on everything below catastrophe. Most edits, I don’t act on the scoring at all — it just sits in context and the model writes correctly the next time. For the one-in-ten edit that would have shipped a real CRUD/FLS gap, the rubric flags it before code review sees it. That’s the test for whether the validator is worth its tokens, and so far it is.
Skills don’t always activate, by the way. They get pulled in by description match against the prompt. I tune the descriptions slightly aggressively — overactivation costs tokens, missed activation costs incidents. I’d rather waste tokens.
Hooks
Four scripts under ~/.claude/hooks/scripts/, wired in ~/.claude/settings.json. Three severity tiers in the main guardrail. CRITICAL blocks. MEDIUM warns. HIGH used to auto-fix, and I’ll get to why it doesn’t anymore in the next section.
--dry-runsfdx (deprecated, use sf) · API versions below v56 · SOQL without WITH USER_MODE--dry-run.The two hooks that are worth the airtime are both Salesforce-shaped.
api-version-check.py runs before every sf project deploy or sf project retrieve. It reads sourceApiVersion from sfdx-project.json, queries the target org’s API version (cached for an hour in $TMPDIR/sf-skills-org-api-version-cache.json), and compares. The motivating incident was a 12-minute validation deploy that bounced because the target org was two versions behind on a Spring ‘25 feature I’d used. Twelve minutes of pipeline time I’m not getting back. The cache lookup is sub-second.
lsp-prewarm.py runs at SessionStart, async. It spawns the Apex, LWC, and AgentScript language servers in the background and sends each an LSP initialize handshake. The Apex LSP needs a JVM, so cold start is around 7 seconds on my machine. After prewarm it’s somewhere near 400ms. If I open Claude and start editing within ten seconds it’s already done. If I’m slower, no harm.
org-preflight.py is the other async one. Calls sf org display, prints the default org, validates the auth token, shows API version. If the org token is expired I find out before I type, instead of after a deploy that fails on auth.
session-init.py is the unsexy synchronous one that makes the async pair work. PID-keyed session directory at ~/.claude/sessions/<PID>/. Dead PIDs get cleaned up on next start. I run multiple Claude sessions side by side and the PID keying keeps them from stepping on each other.
The hook genre is over-saturated and I’m not going to add to it. If you want the gory regex, the file is in the repo.
Agents and MCP
Nine specialist agents under ~/.claude/agents/. The one I actually use is fde-strategist. Opus, plan mode, Edit and Write explicitly disallowed. Its one job is to decompose a feature into work items and delegate. Its tools line:
tools: Read, Grep, Glob, Bash, WebFetch, WebSearch,
Task(fde-engineer, fde-experience-specialist, fde-qa-engineer,
fde-release-engineer, ps-technical-architect, ps-solution-architect)
It spawns up to four specialists concurrently. The specialists run on Sonnet because Sonnet is enough for the actual edits, and cheaper, and faster. My day-to-day chat in the IDE also runs Sonnet. Opus is reserved for the strategist, for the planning step, and for the rare moment where I’d rather pay for thinking than retry on a mistake.
The whole structure is overkill for “fix this bug.” It earns its keep on multi-day work. Agentforce topics. The Buyouts release I just shipped. The Summer ‘26 audit I’m in the middle of.
Two slash commands. /diary writes a structured session entry to .claude/memory/diary/. /reflect reads N entries, looks for patterns that show up twice or more, and proposes additions to CLAUDE.md. The thing I like about /reflect is that it also detects violations of existing rules. If the diary shows me correcting Claude on something CLAUDE.md already says, that’s a higher-priority signal than a new pattern — it means the rule is too weak and needs strengthening, not duplicating.
Now MCP servers, more honest than the section heading lets on. Twelve plugins are enabled. The six I actually touch in a normal week are context7 for live library docs, Atlassian for Jira and Confluence reads, github for PR plumbing, playwright for the ui-tests/ suite, claude-mem for cross-session recall, and claude-hud for the statusline.
- context7
- Atlassian
- github
- playwright
- claude-mem
- claude-hud
- frontend-design
- impeccable
- obsidian
- gif-recorder
- social-image
- everything-claude-code*
The other six are dead weight. frontend-design and impeccable are gorgeous, but I do no real frontend work — they sit idle. obsidian was installed because I thought I’d write notes through the chat, and I didn’t. The zz-skills family (gif-recorder, social-image, write-prompt) is leftover from a different project. everything-claude-code is huge and I use maybe two skills out of it. I’m doing a cleanup pass next week.
Auto-memory lives at ~/.claude/projects/<project-hash>/memory/. Four types of file. user_*.md is who I am. feedback_*.md is what to keep doing and what to stop. project_*.md is decisions with absolute dates. reference_*.md is where things live in external systems. A short MEMORY.md index sits on top and is always loaded. Memory is for the why, not the what.
What broke
The best story first.
The first version of guardrails.py had a HIGH-tier rule that added LIMIT 200 to unbounded SOQL. The regex was greedy. It happily appended past pipe boundaries, so sf data query --query "SELECT Id FROM X" | jq . became sf data query --query "SELECT Id FROM X" | jq . LIMIT 200. jq tried to open a file called LIMIT. Took me longer than I’d like to admit to figure out what was happening, because the hook printed the intended transformation in its log (LIMIT inside the SOQL string) while it actually applied a different one (LIMIT at the end of the shell command). Two grammars going through the same regex, neither parsed properly.
I ripped the auto-fix out. The HIGH tier is empty now. If a hook can’t unambiguously parse the input grammar, it doesn’t get to rewrite. That’s the rule.
The second was the SessionStart race. org-preflight.py and lsp-prewarm.py were both racing to write into a session directory that didn’t exist yet. About one start in twenty, one of them would bail. session-init.py runs synchronously now, creates the directory, lets the async pair fire after. The hook system gives you ordering as a primitive. It took me an embarrassingly long time to actually use it.
The third was CLAUDE.md sizing, which I half-told above. The 300-line version was too much. Twelve small files was also too much. The current 75-line CLAUDE.md plus the .claude/doc/ index is meaningfully faster on first response than either, and I keep wanting to add more lines to it and then deleting them after a week.
What I’d redo
In order of how much it bugs me.
Salesforce-specific guardrails belong in a project plugin, not user-global ~/.claude/hooks/. They’re only valid on a Salesforce repo. They sit in the user dir because that’s where my first prototypes landed and I never moved them. If I open Claude in a non-Salesforce project, api-version-check.py still tries to read sfdx-project.json, and org-preflight.py still tries sf org display. Both fail quietly. About 30 seconds of failure on every wrong-directory session start. Quiet smell.
Hardcoded paths. Half my hooks reference C:/Users/AlexHuang/... absolute paths. Switching machines means a find-and-replace pass. Env-var driven, or a small installer. Haven’t done it because it hasn’t broken yet.
Cost-aware validation. The PostToolUse dispatcher runs on every Write and Edit. A five-line CSS tweak doesn’t need a 150-point Apex rubric. I want to route by diff size and skip the rubric for trivial edits.
Retryable deploy gate. sf-pre-pr-checks runs prettier, eslint, jest, apex tests, and sf project deploy validate --dry-run in series. One flaky test takes the whole thing down. I want each step independently retryable, so I’m not re-paying for the four that already passed.
The harness is a tax. Every token of context, every hook, every skill activation is paid for at the first response. The math only works if the harness prevents more rework than it costs in latency, and that’s the only filter I apply when I’m thinking about adding something to it.
For a hobby project, I wouldn’t build any of this. For a three-region production Salesforce org with an Azure pipeline, AvaTax that’s US-only and unforgiving, and a sprawling @AuraEnabled controller that ten flows depend on, it has paid for itself.
If you’ve built something similar on Salesforce I’d genuinely like to compare CLAUDE.mds. Most of the published harnesses out there are written by people shipping Next.js apps, and the Salesforce equivalents are mostly still on people’s laptops. Hit me up.