Tim Trailor
Essay

Six layers of defence for an AI agent over a 3D printer

The printer-safety setup I now run, the incidents that produced each layer, and the parts you can lift for any agent that touches a physical system.

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_SPEED
  • PAUSE and RESUME
  • CANCEL_PRINT_CONFIRMED (specifically, not CANCEL_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_daemon reads state every 30 seconds during a print, every 5 minutes when idle, and writes a JSON status file. Read-only.
  • plr_autosave.py polls state every 60 seconds and writes the saved-variables file. The only command it sends is the explicit SAVE_VARIABLE it 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

LayerMechanismWhat it catchesWhat it does not
1. Text rulesMarkdown promptThe obvious cases when the agent is paying attentionError-recovery paths, time-pressured paths
2. PreToolUse hookShell script, Claude Code integrationAgent-originated off-allowlist commandsDirect Moonraker API, SSH, physical input
3. Klipper macrosFirmware-level overrideAll callers uniformlyCommands the firmware cannot override (FIRMWARE_RESTART)
4. Daemon state checksPer-daemon guardsDaemon-originated commands in error statesRace conditions in the read-then-send window (rare)
5. Human-only authorityNo agent authority at allFIRMWARE_RESTART in all states, by all actorsHuman error
6. Audit trailLogsNothing; this is diagnosticEvents 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.