print-warden
Integrates with Home Assistant to send alert webhooks, actionable mobile notifications (with Start/Skip buttons), and control a work light. It also receives notification button taps via HA's websocket event bus to authorize print starts.
Integrates with OpenSCAD to render .scad model files into STL for slicing and 3D printing.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@print-wardenslice part.stl for garagex4 in PETG and stage the gcode"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
print-warden
Print manager for the homelab's 3D printers so a Claude session can hand off everything after design.
Slices with a headless OrcaSlicer (Artillery vendor presets + house rules), stages gcode on the printer,
starts jobs only with the operator's permission, then watches: deterministic rules over Moonraker telemetry,
Obico's spaghetti verdicts via webhook, snapshots from any HTTP camera, timelapse frames per layer, and
alerts fanned out through one Home Assistant webhook. Exposes an MCP server (Streamable HTTP at /mcp).
Trust gradient
Action | Who decides |
Slice, stage gcode, snapshots, alerts, work light | the warden, automatically |
Mark the bed clear | the operator: the "Bed cleared" phone button, or quoted chat permission ( |
Start a print | the operator: a phone tap ( |
Start without a tap | the warden, only on an |
Pause on detected failure | Obico |
Cancel or resume a running print | a human: at the printer, or the Cancel / Resume button on a warden notification for that job (no MCP tool can) |
Power off | a human |
Printers with a power_device (the X1's Sonoff) are powered on at start and Klipper is coaxed to ready with
up to three FIRMWARE_RESTARTs before the print command goes out; the tap still gates the whole sequence.
policy.can_start is plain Python: Moonraker connected, Klipper ready, printer idle, gcode staged,
loaded material matches the job, and the active spool has the job's grams plus 10%. No model is consulted in the gate.
A phone tap answers one request_start and is honoured only within policy.approval_ttl_minutes (default 15);
unanswered requests go back to staged and the notification is withdrawn. The Start / Skip notification is sticky on
Android: a tap on its body or a swipe does not dismiss it, only its buttons or the warden's own withdrawal (start, skip,
expiry) do. Starts on one printer are serialised, so a
double tap sends one print.
Related MCP server: Klipper MCP Server
Monitoring
Rules run on every Moonraker update; besides heaters, stalls and connection loss they watch whatever the printer has, discovered from Moonraker's object list on each (re)subscribe:
Fans (
fan,heater_fan …,controller_fan …,fan_generic …): commanded ≥30% while the tachometer reads underfan_min_rpmforfan_stall_s→ critical. Fans without a tachometer (rpmis null) are not judged.Runout sensors (
filament_switch_sensor …,filament_motion_sensor …): filament lost mid-print → warning.Temperature sensors: any whose name looks like a board or host (mcu, host, rpi, cpu) over 75°C, or any sensor over its
rules.temp_limitsentry → warning, once until it cools 5°C.Klipper shutdowns are classified (thermal runaway, heater not heating, thermistor out of range, MCU comms lost, timer too close, TMC driver error, move out of range) with what to check.
At layer first_layer_check (default 2; 0 disables) the phone gets the nozzle camera frame with Looks good /
Cancel print. When Obico pauses a print the phone gets Resume / Cancel print. Both are bound to the job:
the tap only acts if that job is still the one printing (same file on the printer), so a stale button cannot touch a
later print. alerts.quiet_hours mutes info and warning alerts and the first-layer prompt, as does alerts.quiet_entity while
that Home Assistant entity is in alerts.quiet_states (default ["on"]; an unreadable entity never mutes); critical alerts and
Obico pauses always go out. alerts.cooldown_s (default 300) spaces repeats of the same alert.
History and Home Assistant
Every finished job records Klipper's actual print time, the grams used (Klipper's filament length scaled by the
slicer's grams per metre) and, when Spoolman has a price for the spool (spool price / initial weight, else the
filament's), the cost. printer_stats(days=90) reports estimate accuracy per printer and quality, failure rates by
printer / material / quality with the commonest reasons, and prints, hours, grams and cost per month;
print_history(printer, days, state) lists the jobs behind it.
Energy. With a printer's power_entity (a Home Assistant sensor on its plug), every print records its kWh, the
heat-up before the first layer, and an energy cost. An energy counter (kWh, Wh) gives the exact rise from start to
end; a power sensor (W, kW) is sampled every 30 s and integrated, and a gap over 5 minutes (the warden was down)
counts nothing. The price is energy.price_per_kwh, or the mean of energy.price_entity (default: the cheap-power
entity) at start and end. printer_stats adds kWh and energy cost per month and, per printer, mean draw while
printing and mean heat-up; print-warden check confirms each power entity reports a unit it can use.
Klipper knows a print completed, not whether it is any good. After every print the phone gets the end photo with
Good / Failed (a cancelled or errored print goes straight to the reasons): adhesion, spaghetti, layer shift,
under-extrusion, other. The label (or label_print(job_id, result, labelled_by, reason) from chat) wins over how the
job ended in the failure rates and reasons, and submit_job warns when the same geometry failed before with the same
material, quality and overrides (advisory; it never blocks).
Calibration towers. calibrate(printer, kind) slices a five-section tower for the spool loaded on the printer and
queues it like any job (it starts on a tap). temp steps the nozzle from 10 °C above the spool's temperature to 10
below, flow the extrusion multiplier from 104 % to 96 %, pa Klipper's pressure advance (bowden=True for a
Bowden range). The warden inserts each section's command after slicing, checks temperatures as usual, and puts flow and
pressure advance back at the end. Afterwards the phone asks which section looked best; the answer is saved on the
spool in Spoolman (pw_nozzle_temp, pw_flow_ratio, pw_pressure_advance; record_calibration from chat) and that
spool's later jobs are sliced with it.
Upkeep runs hourly and sends each finding to the phone at most once a day, outside quiet hours:
Reorder: a material whose Spoolman stock, minus what the queue still needs, is under
upkeep.reorder_below_g.Drying: a loaded spool of a material in
upkeep.dry_after_daysthat has been open (or since it was last dried) longer than that. The Dried button records it.Maintenance: per printer,
maintenance: {nozzle: {every_hours: 300}, belts: {every_days: 90}}. Hours are the print time since the item was last done. Done resets it, as doesrecord_maintenance_donefrom chat. Each item is asensor.print_warden_<printer>_maint_<item>showing the share of its interval used. A new item starts counting when it is first seen.
upkeep_status shows all three.
Each printer's ETA correction is the median actual/estimate ratio of its last 20 completed prints (once there are 3,
clamped to 0.5–2×). It is applied wherever an estimate is used: routing, finish_window, notifications and
est_human ("1h 12m (Orca 1h 0m)").
With home_assistant.publish_entities (default on) the warden keeps sensor.print_warden_<printer>_{state, progress, finish, queue, bed, material} up to date through HA's REST states API, using the token it already has: changes every
30 s, everything every 5 min (HA forgets REST-set states on restart). These entities have no unique_id, so they cannot
be renamed or assigned to an area in the HA UI. With home_assistant.mqtt set (the broker HA's MQTT integration uses)
the same entities are published by MQTT discovery instead: same entity ids, but with unique_ids and one device per
printer, retained on the broker so they survive an HA restart, and unavailable while the warden is down.
Bed-clear check and auto_start
The bed is a recorded fact: a print starting or ending marks it occupied, and every start is blocked until a human
marks it clear (the "Bed cleared" phone button sent after each print, or mark_bed_clear with the operator's words).
With bed_check configured, the first "Bed cleared" also captures the empty-bed reference from that camera (light on).
After that, the warden compares the camera to the reference whenever a human knows the answer: ~30 s after a print
ends (should read occupied) and on every "Bed cleared" (should read clear). The comparison is plain image arithmetic:
crop to region, match brightness per colour channel, count pixels that differ. score is the fraction that
differs; threshold (default 0.002) separates clear from occupied. Use check_bed to see live scores and tune it,
and capture_bed_reference after changing the build plate (the record starts over).
On a bed-slinger with a frame-mounted camera the bed's Y position changes the picture, so set bed_check.pose. The
warden then edits the end gcode of every job it slices: a park block (Z up to at least pose.z and clear of the part,
bed to pose.y) goes in before the stepper-off line, and the M84/M18 lines are dropped so the steppers hold the
bed until Klipper's idle_timeout releases them (raise idle_timeout if you clear beds later than its default
10 min). Checks and reference captures run only while the toolhead is homed at the pose; otherwise the check reports
"not at the bed-check pose" and records nothing, and auto_start refuses. With rehome_y: true an idle printer whose
steppers were released is brought back with G28 Y and a Y move (X too if the pose sets it); Z never moves, the last
commanded Z must be at least pose.z, and every rehome alerts. Prints the warden did not slice have no park block.
auto_start: true starts the queue's next job without a tap only when the bed is marked clear, a live check reads clear,
the last min_agreements labelled checks all agreed with the operator, and the local time is inside
auto_start_hours. Each unattended start sends an alert with the check's snapshot. A missed small part (a false
"clear" after a print) breaks the record, so auto_start stops until the check earns it back.
Did the part stay on the bed. Klipper reports a print complete even when the part came off halfway. Once the bed
camera is calibrated (calibrate_bed_camera: the printable area's four corners in a snapshot at the bed-check pose,
as image fractions), the post-print check also projects where the job's first layer was drawn into the frame. If a
part is not seen there ("probably failed: 1 of 3 parts not seen..."), or much of the bed away from the parts changed
(debris, spaghetti), the outcome prompt is re-sent with that hint. The hint is kept on the job (outcome_hint); a
person still decides. footprint_check(job_id) runs it on demand. Deterministic image arithmetic, no model.
Job flow
curl -F file=@part.stl http://nas.local:8720/upload # or a .scad / .3mf
submit_job(printer="garagex4", model="part.stl", material="PETG", quality="standard", copies=1)
-> checks the model fits, slices (tree supports, no brim), checks the gcode, uploads it to Moonraker as
warden/<job>_<name>_<mat>.gcode and queues it; printer may be a location ("wyola")
job_status(job_id) # estimates, warnings, slice blockers, can_start + blockers
queue("garagex4") / move_job(job_id, 0) / offer_next("garagex4")
request_start(job_id) # phone notification with Start / Skip buttons (the queue does this for you)
waive_blockers(job_id, authorized_by="the operator said: print it anyway")
start_print(job_id, authorized_by="the operator said: go ahead and print it") # chat path
list_spools() / set_active_spool("garagex4", 2) # Spoolman owns spools; the warden records which one is loadedWhen a print ends the warden deducts the filament Klipper reports from the job's spool in Spoolman (set
track_spool_usage: false on a printer whose Moonraker runs its own [spoolman] component), renders the per-layer
frames into timelapse.mp4, and sends the completion alert with actual vs. estimated time, grams and the timelapse link.
On every Moonraker (re)connect, jobs whose outcome happened while the warden was away are settled from Moonraker's
print history.
Queue. Staged jobs line up per printer (queue, move_job). The warden offers the next one (phone tap, or
auto_start) when the printer is idle, its bed is not occupied and nothing is already waiting: right after
submit_job, and whenever the bed is marked clear. One offer at a time; a Skip or an expired request offers nothing
more until the next trigger or offer_next. submit_job(..., offer=False) stages a job without queueing it.
Routing. A location picks the printer with the job's material loaded that frees up soonest (what is left of the
current print plus its queue); printers that cannot print are skipped. finish_window ("07:00-23:00") keeps
auto_start from beginning a job that would finish outside it, and puts the expected finish in a tapped request.
Splitting. submit_split(where, model, material, copies) spreads copies over the printers at a location (or
any, or "a,b") that have the material loaded. Each printer is timed with one copy (corrected, cached). Copies then
go to whichever printer would finish its share soonest, within what fits on its plate. Every share is an ordinary job,
offered and started by tap or auto_start like any other. The result shows the expected finish against one printer
doing it all.
Batches. submit_batch(where, items) queues several prints at once; each item is {model, material, quality?, plates?, copies?, file?, overrides?, name?}, and plates: "all" makes one job per plate of a 3MF project. Every job
is timed on each printer that can take it (material loaded, profile, fits its plate); the longest go first, each to the
printer that would finish it soonest. Every job is ordinary: queued, offered and started by tap or auto_start. The
result says when each printer should be done and whether Spoolman's stock covers the batch after the rest of the
queue. batch_status(batch_id) shows progress (also on /status); save_batch / submit_batch(..., save_as=...)
keep a set ("the usual set") for queue_saved_batch(name); list_saved_batches, delete_saved_batch.
Cheap power. With policy.cheap_power (entity, max_price, min_hours), the offer of a job expected to run at
least min_hours waits while the price entity reads above max_price, and is retried hourly. With min_kwh and a
metered printer, the job's expected energy (its corrected estimate times the printer's measured draw over 90 days)
decides instead of its hours. An unreadable price never holds a job, and an explicit request_start is never held.
Slice checks. Before slicing, the model's bounding box (rotated 90° if that fits) and copies are checked against
the machine's printable area and height; a model that cannot fit is refused. After slicing, the extrusion extents are
checked against the bed, the nozzle and bed temperatures against the material's range, and Orca's log against
slicer.blocking_warnings. Findings become slice blockers: can_start refuses and the queue passes the job over
until waive_blockers records the operator's consent. copies uses Orca's --clone-objects + --arrange;
orient=True uses --orient (the pre-slice fit check is skipped then; the gcode check still runs).
Slicing quality. An identical request (same model geometry, same final machine/process/filament settings,
copies, orient, park pose and Orca binary) reuses the earlier slice from slice-cache/ (pruned after keep_days
unused). Each job records a hash of its geometry and the overrides used: model_history("bracket.stl") lists earlier
prints of that model with their settings and outcomes, and submit_job returns the same summary as history, so a
print that worked can be repeated and one that failed is not retried blindly. Nothing is applied automatically. With
use_spool_temps (default) the loaded spool's Spoolman temperatures (settings_extruder_temp, settings_bed_temp)
replace the profile's when its material matches the job. The house rules set curr_bed_type: High Temp Plate:
OrcaSlicer otherwise picks the Cool Plate temperatures, which in the Artillery profiles is a 35°C bed for PLA.
Qualities: draft (0.24), standard (0.20), strength (0.20), fine (0.12). Materials: PLA, PETG, ABS, ASA, TPU,
PLA-CF (Artillery Generic profiles; temperature checks cover all six).
Models are STL, SCAD or 3MF, up to 100 MB. A 3MF project (Orca, Bambu Studio, PrusaSlicer) is sliced whole, so
modifiers, negative parts and per-object settings apply, with this printer's machine, process and filament settings
loaded over it; plate picks one plate per job. Before slicing, every gcode setting in the project (*_gcode,
post_process) is replaced and embedded gcode dropped, so a downloaded project cannot run its own gcode; if one of
those settings still reaches the output where the printer profile defines it, the job is blocked. With copies or
orient a 3MF is sliced as geometry only.
Model pages. model may also be a model page: model_page(url) shows its title, author, license and model
files, and submit_job(..., model=<page>, file="lid") prints one of them (file is needed when there are several).
The page, author and license stay on the job (print_history). Thingiverse goes through its API and needs an app
token in THINGIVERSE_TOKEN (intake.thingiverse_token_env to rename it); Printables goes through the GraphQL
endpoint its site uses, which is unofficial and may change; any other page is searched for links ending in
.stl / .3mf / .scad, with og:title, the author meta and a rel="license" link.
overrides on submit_job are OrcaSlicer process keys applied last (e.g. {"enable_support": "0"}).
Printer profiles
Any OrcaSlicer printer works, not only the Artillery set: presets are looked up by name in the warden's own profile
directory (slicer.user_dir, default <storage>/profiles), then slicer.vendor_dir, then Orca's filament library,
then every other vendor next to it (slicer.profiles_root, default the vendor directory's parent). A preset's parents
resolve in its own vendor first, as in Orca. print-warden profiles sets a printer up:
print-warden profiles vendors # Orca's bundled vendors
print-warden profiles printers Creality # that vendor's printers
print-warden profiles block "Creality K1 (0.4 nozzle)" # the printers.yaml slicer block to paste
print-warden profiles import my-k1.orca_printer "My PETG.json" # your own Orca / Bambu Studio presets or bundles
print-warden profiles klipper "Voron 350" http://voron.local:7125 # a printer profile from Klipper's configblock keys the processes Orca lists as compatible with the printer by quality (standard, draft, fine, ...;
a repeated name gets its layer height, standard_0.28), and picks per material the warden checks your own filament,
else the vendor's generic one, else Orca's universal one. import writes presets (.json, .orca_printer,
.orca_filament, .bbscfg, .bbsflmt) to slicer.user_dir; a preset whose parent is in neither the bundled
profiles nor the same import is refused, and a process's post_process (a command Orca would run on the warden's
host) is dropped. klipper reads the bed from the stepper limits, the height, nozzle, kinematics, speed and
acceleration limits and the PRINT_START/START_PRINT and PRINT_END/END_PRINT macros (with the parameter names
the macro reads, e.g. BED_TEMP / EXTRUDER_TEMP); it inherits Orca's generic Klipper printer for that nozzle, so its
processes and Orca's universal filaments apply. Without a start macro it heats, homes and draws a purge line; check that
start gcode before the first print. Then run print-warden check.
Some bundled start gcode purges outside the printable area (the Anycubic Kobra 2 Pro purges at Y226 on a 220 mm
bed). The part must always extrude on the printable area; the start gcode's purge may also use the printer's
purge_zone ([x0, y0, x1, y1] in bed mm, a strip the head reaches). Without one, the job is blocked and the
blocker suggests the zone to set: check that the printer really reaches it before setting it.
print-warden profiles diff takes a snapshot of every preset printers.yaml uses, flattened; run it again after an
OrcaSlicer upgrade to see each setting that changed per printer, then --accept the new snapshot.
print-warden check warns while the presets differ from the snapshot. check also validates THINGIVERSE_TOKEN
when it is set, and with --sites that Printables still answers the warden's query.
Run
cp printers.example.yaml printers.yaml # edit
printf 'HA_BEARER_TOKEN=...\nOBICO_WEBHOOK_SECRET=...\nPRINT_WARDEN_TOKEN=...\nHA_ACTION_SECRET=...\n' > .env
docker compose run --rm --build print-warden print-warden check # every profile, service and camera answers
docker compose up -d --build # Ubuntu 24.04 + OrcaSlicer 2.4.2 AppImage + OpenSCAD + ffmpeg
curl localhost:8720/healthzReleased images: ghcr.io/moellere/print-warden:<version>. Operating, backups and troubleshooting:
docs/operations.md; releases: docs/releasing.md; changes: CHANGELOG.md.
Access:
Route | Guard |
|
|
|
|
|
|
| open, so the phone can load notification images; databases and uploaded models are never served |
| open |
/status (open it once with ?token=… and bookmark it) is one read-only page for the house: per printer its state,
progress and finish time, bed, queue with estimates, latest snapshot and maintenance due, then filament to reorder or
dry. It refreshes every 30 s and has no buttons; starts, cancels and bed-clears stay on the phone.
/stage (also opened with ?token=…) adds a print from a browser: a file or a link, where, material, quality,
copies and 3MF plate. Slice shows the preview, estimate, warnings and blockers; Add to queue puts it in the
printer's queue, where it is offered on the phone like any job, and Discard drops it. The page never starts,
cancels or waives anything, and its buttons only act on jobs staged from it. Forms carry a CSRF token (valid for up to
two hours) and must be posted from the page's own origin.
Snapshots and timelapse frames older than storage.keep_days are deleted daily along with old events; rendered
timelapses, job artifacts and uploads are kept.
Obico → Preferences → Notifications → Webhook URL: http://<host>:8720/obico/webhook?secret=<OBICO_WEBHOOK_SECRET>.
Phone buttons come back through HA's websocket event bus (mobile_app_notification_action), no HA config needed.
Layout
src/print_warden/
config.py YAML -> dataclasses; secrets from env
moonraker.py websocket subscriber (read) + upload/start (write, policy-gated)
rules.py telemetry rules -> events
watcher.py per-printer orchestration, snapshots, light, timelapse, job tracking
slicer.py OrcaSlicer CLI wrapper, preset lookup and flattening, gcode header parsing, OpenSCAD render
jobs.py job ledger (SQLite): transition table, transition log
policy.py can_start(), approval_expired()
reconcile.py settle jobs from Moonraker history after a restart or outage
retention.py snapshot / frame pruning
timelapse.py frames -> mp4 (ffmpeg)
bedcheck.py empty-bed comparison + bed ledger (state, labelled checks)
stats.py estimate accuracy, ETA correction, failure rates, monthly usage
ha_entities.py per-printer HA sensors (REST states API)
checks.py slice checks: model fit, gcode extents, temperatures, blocking Orca warnings
preview.py job preview drawn from the gcode (top-down extrusion on the bed outline)
park.py bed-check pose: end-gcode park at slice time, pose test, opt-in Y rehome
ha.py alert webhook, actionable notifications, light control
ha_events.py HA websocket: notification button taps
spoolman.py Spoolman client (materials, remaining weight)
threemf.py 3MF plates to STL, project sanitizing (no project gcode reaches the printer)
footprint.py bed-camera homography, first-layer footprint, part-on-bed verdict
calibration.py temperature / flow / pressure-advance towers and their results
intake.py model pages (Thingiverse, Printables, any page) -> files, title, author, license
status.py the read-only /status page
stage.py the /stage page: slice and queue from a browser, never start
profiles.py Orca vendor listing, slicer blocks, preset import, profiles from Klipper
server.py MCP tools + HTTP routes (/upload, /status, /stage, /obico/webhook, /ha/action, /files, /healthz)Develop
uv sync --group dev
uv run ruff check src tests && uv run ruff format --check src tests && uv run mypy src && uv run pytest -qCI runs the same four on every push and pull request.
This server cannot be deployed
Maintenance
Related MCP Connectors
MCP server for your apps' tools and custom tools, plus hosted AI agents and approval-gated workflows
Nifty's MCP server — exposes tasks, projects, messages, and files as tools for AI agents.
OCR, transcription, file extraction, and image generation for AI agents via MCP.
- UnifAPIOAuthcom.unifapi
Hosted MCP server for live public-data APIs and Skills for AI agents.
Related MCP Servers
- AlicenseBqualityAmaintenanceEnables interaction with 3D printer management systems through an MCP server, supporting STL file manipulation, slicing, and control of printers like OctoPrint, Klipper, Duet, and more.30186 npm251GPL 2.0
- FlicenseNot gradedqualityBmaintenanceAn MCP server that enables AI assistants to control and monitor Klipper 3D printers via the Moonraker API. It supports comprehensive printer management, including G-code execution, toolchanger operations, and real-time status monitoring.27-
- AlicenseNot gradedqualityCmaintenanceEnables MCP clients to submit, slice, and start 3D prints with configurable gates, preview approval, and monitoring, via a self-hosted print daemon.MIT
- AlicenseNot gradedqualityBmaintenanceEnables AI agents to manage multiple 3D printers via MCP, including monitoring temperatures and progress, uploading and slicing models, controlling prints, and viewing cameras.8 npmMIT