I have ended up with six layers of defence between Claude Code and the three Klipper-based printers on my network. Each layer was added because the one above it failed during a specific incident. If you are at the “rule in a markdown file” stage, this is the route to the other five without losing the prints I lost.
The starting point was a rule in plain text: “never restart during a print.” The agent loaded it every session. Twice, while recovering from an error state, the agent sent the restart anyway.
What we are defending
A 3D printer is a cheap example of an expensive class of problem: an AI agent with tool access, driving a physical system on a long time horizon, where a wrong command costs filament plus a day of print time.
The specific printer is a Sovol SV08 Max running the open-source firmware stack Klipper, Moonraker and Mainsail, with Mainsail providing the web interface. The agent reaches it through Moonraker’s HTTP API, the G-code upload path, Klipper’s macro system once a print is running, and SSH to the small Linux board (running Armbian) that hosts the printer’s controller.
A command that is safe at idle may be unsafe during a print. FIRMWARE_RESTART is the clearest case: harmless when idle, catastrophic mid-print. SAVE_CONFIG is the same. G28 (home all axes) is the same. An agent left alone will hit one eventually.
Layer 1: text rules
What it is. A markdown file at ~/.claude/rules/printer-safety.md that the agent loads every session. It lists allowed commands during a print and names FIRMWARE_RESTART and SAVE_CONFIG as never-without-permission.
What it catches. The obvious cases, when the agent is paying attention.
What it does not catch. The agent recovering from an error state. The agent under time pressure. The agent that checked state once and did not re-check. The 5 March 2026 SAVE_CONFIG incident sat here: the agent had read the rules, then later sent the command without re-reading.
The lesson. A text rule is a request, not a guard.
Layer 2: a PreToolUse hook
What it is. A shell script at ~/.claude/hooks/printer-safety-check.sh registered as a PreToolUse hook. Claude Code runs it before every tool call. The hook reads the tool name and arguments from stdin, checks whether the call is sending G-code to a printer, and if so queries Moonraker for current state.
If print_stats.state is “printing” or “paused”, only seven commands are permitted:
M117(display a message on the printer screen)SET_GCODE_OFFSET(Z-offset adjustment, common for baby-stepping)M220(speed factor, bounded 50% to 150%)M221(flow rate, bounded 80% to 120%)SET_FAN_SPEEDPAUSEandRESUMECANCEL_PRINT_CONFIRMED(specifically, notCANCEL_PRINT)
Anything else returns exit code 2, which Claude Code reads as “deny”, and the tool call never runs.
What it catches. Every off-allowlist command the agent has tried to send. The day after install, the daemon sent FIRMWARE_RESTART, the hook intercepted, and the print continued.
What it does not catch. Commands sent to Moonraker’s HTTP API outside Claude Code, SSH sessions, sessions where the hook has been edited or disabled, and commands on the allowlist but contextually wrong (eg M220 50% when the print needs M220 100%).
The code. The core check:
# Get state from Moonraker
STATE=$(curl -s --max-time 2 \
"http://192.168.0.108:7125/printer/objects/query?print_stats" \
| jq -r '.result.status.print_stats.state')
if [[ "$STATE" == "printing" || "$STATE" == "paused" ]]; then
CMD=$(echo "$INPUT_JSON" | jq -r '.command // ""')
if ! grep -Fxq "$CMD" "$ALLOWLIST"; then
echo '{"decision":"deny","reason":"blocked by printer-safety allowlist"}'
exit 2
fi
fi
The full version logs every decision to ~/.claude/printer_audit.log and fails safe (deny) when Moonraker is unreachable.
The lesson. Hooks turn a rule into a guard. Every command this hook has blocked since install would otherwise have been sent.
Layer 3: Klipper macros that block themselves
What it is. Two Klipper macros that refuse to run in an unsafe state, both in ~/printer_data/config/Macro.cfg on the printer.
The first wraps SAVE_CONFIG. Klipper’s built-in version can be overridden, and the override checks print_stats.state first:
[gcode_macro SAVE_CONFIG]
rename_existing: SAVE_CONFIG_ORIG
gcode:
{% if printer.print_stats.state in ["printing", "paused"] %}
{ action_raise_error("SAVE_CONFIG blocked: print in progress") }
{% else %}
SAVE_CONFIG_ORIG
{% endif %}
The second wraps G28. On 7 April 2026 I found that Klipper sets print_stats.state to “printing” before the start-of-print G-code runs, so a naive state check would block the G28 in the start macro, which is exactly when homing is needed. The fix was to also check whether axes are already homed:
[gcode_macro G28]
rename_existing: G28_ORIG
gcode:
{% set homed = printer.toolhead.homed_axes %}
{% if printer.print_stats.state in ["printing", "paused"] and homed == "xyz" %}
{ action_raise_error("G28 blocked: print in progress") }
{% else %}
G28_ORIG { rawparams }
{% endif %}
What it catches. These macros fire whatever the caller is, whether HTTP, SSH, the touchscreen, or another macro. The 5 March 2026 failure mode became impossible at this layer.
What it does not catch. FIRMWARE_RESTART, a Klipper internal rather than a G-code macro and cannot be overridden this way. Touchscreen commands during a state the macros consider recoverable.
The lesson. A guard at the firmware layer applies to every caller. The agent does not need to know it exists.
Layer 4: daemon state checks
What it is. Every long-running daemon on the Mac Mini that talks to the printer re-checks print_stats.state before each command. Daemons poll, so a single check at task start is not enough.
printer_snapshot_daemonreads state every 30 seconds during a print, every 5 minutes when idle, and writes a JSON status file. Read-only.plr_autosave.pypolls state every 60 seconds and writes the saved-variables file. The only command it sends is the explicitSAVE_VARIABLEit was built for.printer_daemon.py’s auto-recovery path was rewritten in March 2026 to block FIRMWARE_RESTART when state is “printing” or “paused”. Before the rewrite, this daemon caused the 11 March 2026 incident.
What it catches. Daemon-originated commands in error states. The 11 March 2026 failure required the daemon to send FIRMWARE_RESTART; the layer-4 check makes that path impossible.
What it does not catch. A daemon that read state, got “idle”, and is about to send a command when a new print starts in the gap between read and send. I have not seen this race; if it happens, layer 3 catches it.
The lesson. Daemons are a separate set of actors from the agent, with their own polling loops and their own ways to do harm. Treating them as background plumbing was the original error. Any daemon that touches external state needs the same questions as a new agent automation: what can it send, does it state-check before every action, what happens on network drop, can I stop it with one command?
Layer 5: human-only authority for FIRMWARE_RESTART
What it is. FIRMWARE_RESTART (and its sibling RESTART) is the one command where no automation is enough. The agent never sends it, in any state, and only I can authorise it.
The rule is in ~/.claude/rules/printer-safety.md in bold, enforced at layer 2 (the hook denies it unconditionally), enforced at layer 4 (daemons refuse to send it), and repeated to every Claude Code session.
What it catches. Cases where the idle-state guard would legitimately allow FIRMWARE_RESTART (after a print finishes cleanly, for instance) but where I do not want the agent making that call. The agent’s error on 11 March 2026 was not that FIRMWARE_RESTART was wrong in principle. The error was that the agent was wrong about state. Removing the authority entirely removes the class of error.
What it does not catch. Me. Layer 5 is about authority, not safety.
The lesson. For a small set of operations with asymmetric downside, the only safe rule is absolute. If the fleet needs FIRMWARE_RESTART, I do it.
This follows a pattern of escalating corrections. The same error was corrected four times (“check state”, “never restart without permission”, “never restart even after print”, “macro block at firmware level”) before the rule held. By the time the rule was “never send this, ever”, it was strong enough.
Layer 6: the audit trail
What it is. Every printer command, tool call, hook decision and state query is logged. The layer-2 hook writes to ~/.claude/printer_audit.log, the daemons write to /tmp/printer_status/snapshot_daemon.log, and Klipper’s own logs sit at ~/printer_data/logs/klippy.log.
The audit trail explains incidents after the fact rather than preventing them. For the 5 March 2026 SAVE_CONFIG incident the trail showed the exact sequence: clog detector fired, recovery chain invoked, the power-loss recovery save ran, something in that chain triggered SAVE_CONFIG, config flushed, firmware restarted. Without the trail I would have guessed. With it, I found the wrong handler and fixed it the same evening.
What it catches. Nothing during the incident. The audit layer is for the postmortem, and it is what makes the previous five improvable.
What it does not catch. Events outside its coverage. I now also log every direct Moonraker HTTP call from the Mac Mini, every SSH session to the printer host, and every touchscreen input.
The lesson. Defence in depth without a diagnostic layer leaves you guessing at causes. The audit trail is what lets me turn each incident into a new macro, hook or daemon check.
The architecture, assembled
| Layer | Mechanism | What it catches | What it does not |
|---|---|---|---|
| 1. Text rules | Markdown prompt | The obvious cases when the agent is paying attention | Error-recovery paths, time-pressured paths |
| 2. PreToolUse hook | Shell script, Claude Code integration | Agent-originated off-allowlist commands | Direct Moonraker API, SSH, physical input |
| 3. Klipper macros | Firmware-level override | All callers uniformly | Commands the firmware cannot override (FIRMWARE_RESTART) |
| 4. Daemon state checks | Per-daemon guards | Daemon-originated commands in error states | Race conditions in the read-then-send window (rare) |
| 5. Human-only authority | No agent authority at all | FIRMWARE_RESTART in all states, by all actors | Human error |
| 6. Audit trail | Logs | Nothing; this is diagnostic | Events outside coverage (narrow gap) |
No single layer is trusted to be enough, and each catches what the layer above misses. The setup was tested in April 2026 during a multi-model audit, when two Claude Code sessions plus Gemini and GPT-5.4 were reviewing the entire system while a 20-hour print was running on the primary printer. The print completed. Every layer held.
Generalising the pattern
This is about a 3D printer, but the pattern is about any AI agent operating on a physical system or on irreversible state. Text rules are not enough because the agent will route around them under pressure. The fix is progressive enforcement: a layer closer to the hardware than the agent can see, on the assumption that the layers above will eventually fail.
For production databases, replace FIRMWARE_RESTART with DROP TABLE, the Klipper macro with a database trigger that refuses destructive operations during a migration, the PreToolUse hook with a database role that lacks schema-changing permissions, and layer 5 with the rule that schema changes need explicit human approval regardless of environment.
For cloud infrastructure, replace with identity and access boundaries, Terraform plan-gates, and manual promotion to production. The layer furthest from the agent matters most.
What I would tell a past me starting this
Build layer 1 first. It will feel like enough, and it will work for a while. Then you will lose a long print.
Build layer 2 immediately after the first incident, not the third. Build layer 3 before the first macro-layer incident, because that incident will cost you a print you cared about; layer 3 is the one I most regret not having first.
Layers 4 and 5 will feel redundant at the time. They catch the cases you could not imagine when you started.
Layer 6 is not optional. Without a full audit trail every bug report will include “I think the agent did X before Y happened, but I’m not sure.”
The total code for all six layers is small: one markdown file, one bash script, two short Klipper macros, two daemon state guards, one rule. The work is in stacking layers until the class of failure is no longer possible, instead of stopping at the first layer that caught the last incident.
If you are building something similar and want to compare notes, the contact form is on the about page.
Code referenced: ~/.claude/rules/printer-safety.md, ~/.claude/hooks/printer-safety-check.sh, Klipper macros SAVE_CONFIG, G28, CANCEL_PRINT_CONFIRMED in Macro.cfg, daemon printer_daemon.py in sv08-print-tools. The full control plane repo will be published separately.