Skip to content

Settings reference ​

Every user-adjustable setting, its YAML key, its environment variable and its value in the Docker configuration (config/docker.yaml over config/home.yaml and the printer model). tests/test_docker_release.py derives the inventory from the settings schema and fails when a setting lacks a row here or an .env.example line, or when a documented name is not a real setting.

  • YAML: put keys in a file and select it with PRINT_HANDY_CONFIG. In Docker, mount the file and point PRINT_HANDY_CONFIG at its container path.
  • Environment: PRINT_HANDY__SECTION__KEY=value overrides any file. Booleans accept true/false, 1/0, yes/no, on/off. Integer settings need integer spelling (12, not 12.0). Rows marked file only are lists or tables and can only come from YAML or legacy TOML; the one list exception is delivery.methods, which also accepts a comma- or space-separated environment value (PRINT_HANDY__DELIVERY__METHODS=discord,bambu_handoff,printer, #270).
  • Precedence, later wins: vendor and printer model defaults (model), config/home.yaml, the selected config file, environment. Settings are read once at start, so restart the service after a change.
  • A typo or invalid value stops startup and names the setting. YAML holds no secrets; see Secrets.

Schema, validation limits and TOML migration: CONFIGURATION-YAML.md. Standalone bot settings: STANDALONE.md. Printer vendor profiles: PRINTER-PROFILES.md.

YAML keyEnvironment variableDocker valueWhat it does
layout.single_copy_edge_mmPRINT_HANDY__LAYOUT__SINGLE_COPY_EDGE_MM1.0 modelBed-edge clearance for one copy
layout.multi_copy_edge_mmPRINT_HANDY__LAYOUT__MULTI_COPY_EDGE_MM3.0 modelBed-edge clearance for several copies
layout.merge_edge_margin_mmPRINT_HANDY__LAYOUT__MERGE_EDGE_MARGIN_MM8.0 modelEdge margin when merging plates
layout.merge_spacing_mmPRINT_HANDY__LAYOUT__MERGE_SPACING_MM10.0 modelGap between merged plates' parts
layout.max_merged_hoursPRINT_HANDY__LAYOUT__MAX_MERGED_HOURS12Merged plates longer than this stay separate
prime_tower.size_mmPRINT_HANDY__PRIME_TOWER__SIZE_MM45.0 modelPrime tower size
prime_tower.part_clearance_mmPRINT_HANDY__PRIME_TOWER__PART_CLEARANCE_MM8.0 modelPrime tower to part clearance
prime_tower.edge_mmPRINT_HANDY__PRIME_TOWER__EDGE_MM10.0 modelPrime tower to bed edge clearance
prime_tower.merge_slack_mmPRINT_HANDY__PRIME_TOWER__MERGE_SLACK_MM5.0 modelExtra room kept for the tower when merging
printer.firmwarePRINT_HANDY__PRINTER__FIRMWAREZMod Klipper modelFirmware label shown in job summaries
printer.start_macroPRINT_HANDY__PRINTER__START_MACROSTART_PRINT modelKlipper start macro for stock Klipper models
printer.end_macroPRINT_HANDY__PRINTER__END_MACROEND_PRINT modelKlipper end macro for stock Klipper models
printer.namePRINT_HANDY__PRINTER__NAMEFlashforge AD5X modelPrinter name shown in job summaries
printer.bed_mmfile only[220.0, 220.0] modelUsable print area [x, y] in mm
printer.max_print_height_mmPRINT_HANDY__PRINTER__MAX_PRINT_HEIGHT_MM220.0 modelTallest allowed print
printer.nozzle_diameter_mmPRINT_HANDY__PRINTER__NOZZLE_DIAMETER_MM0.4 modelInstalled nozzle; slicing refuses any other
printer.ifs_slotsPRINT_HANDY__PRINTER__IFS_SLOTS4 modelFilament slots; 1 for a single extruder
printer.tool_countPRINT_HANDY__PRINTER__TOOL_COUNT1 modelIndependent toolheads on a tool-changer model (Creator 5 / 5 Pro: 4); slicer tool i is toolhead i, never an IFS slot
printer.modelPRINT_HANDY__PRINTER__MODELflashforge.ad5xPrinter brand and model: flashforge.ad5x, sovol.sv06, klipper.generic, octoprint.marlin, bambulab.a1, bambulab.p1s (Bambu Lab models are offline only). Sets every model default marked model
printer.vendorPRINT_HANDY__PRINTER__VENDORflashforge modelVendor part of the model key, set by the model; vendor plus a bare printer name is also accepted
printer.slicerPRINT_HANDY__PRINTER__SLICERorca modelSlicer: orca everywhere; Bambu Lab printers default to bambu_studio and may pick orca
slicer.flash_studio_pathPRINT_HANDY__SLICER__FLASH_STUDIO_PATHemptyOptional path to a Flash Studio executable the user already installed. Empty keeps OrcaSlicer. A set but missing or non-executable path is an error, not a fallback. Never downloaded or bundled.
printer.nozzle_typePRINT_HANDY__PRINTER__NOZZLE_TYPEunsetOptional nozzle material, limited to the model's supported types
orca.templatesPRINT_HANDY__ORCA__TEMPLATESthe model's folder modelFolder with the Orca machine, process and filament JSON
orca.commandfile only["/usr/local/bin/orca-slicer"]How to start the OrcaSlicer CLI; PRINT_HANDY_ORCA_CHANNEL selects stable (default) or alpha; both binaries exist only when WITH_ORCA=true
delivery.methodsPRINT_HANDY__DELIVERY__METHODS["printer"] modelPost-slice options offered, from the vendor profile (for example printer); comma- or space-separated in the environment, an empty value keeps the file value
bambu.commandfile only["/usr/bin/bambu-studio"]Bambu Studio CLI: the default slicer for Bambu Lab printers, Orca's one-shot fallback elsewhere; exists only in a WITH_BAMBU=true image
bambu.lan_hostPRINT_HANDY__BAMBU__LAN_HOST""Bambu Lab printer LAN address for read-only status (#187); must be set together with lan_serial; empty disables LAN status
bambu.lan_serialPRINT_HANDY__BAMBU__LAN_SERIAL""The Bambu Lab printer's serial number (stored upper case); used in the MQTT topics, not as a credential
bambu.lan_access_code_envPRINT_HANDY__BAMBU__LAN_ACCESS_CODE_ENVBAMBU_LAN_PASSWORDName of the environment variable holding the printer's LAN access code; the code itself is never a setting
bambu.start_confirm_sPRINT_HANDY__BAMBU__START_CONFIRM_S90Bambu Lab LAN print start (#271): how long, in whole seconds (1-300), the printer is polled every 10 s to report the submitted file as preparing or running before the start is left as uncertain; keep it well under the bot's 420 s start budget, which also covers the upload
flashforge_stock.hostPRINT_HANDY__FLASHFORGE_STOCK__HOST""Stock Flashforge printer LAN address; empty leaves the printer offline. The serial and check code stay in AD5X_SERIAL and AD5X_CHECK_CODE, never in a setting
prusa.link_hostPRINT_HANDY__PRUSA__LINK_HOST""Prusa printer LAN address for PrusaLink; empty leaves the printer offline (slicing, checks and Discord delivery only). Prusa models only
elegoo_sdcp.hostPRINT_HANDY__ELEGOO_SDCP__HOST""Centauri printer LAN address for SDCP; empty leaves the printer offline (slicing, checks and Discord delivery only). Elegoo SDCP models only. SDCP has no authentication; there is no key to configure
prusa.api_key_envPRINT_HANDY__PRUSA__API_KEY_ENV"PRUSA_LINK_API_KEY"Name of the environment variable holding the PrusaLink API key. The key itself is never a setting
paths.hermes_homePRINT_HANDY__PATHS__HERMES_HOME/data/hermesState folder (state/, logs/); the name is historical, Hermes is not required
paths.archivePRINT_HANDY__PATHS__ARCHIVE/data/archiveJob archive
paths.records_vaultPRINT_HANDY__PATHS__RECORDS_VAULT/data/recordsPrint records: one JSON file per print in Prints/
paths.cachePRINT_HANDY__PATHS__CACHE/data/cacheRegenerable cache
paths.hermes_binPRINT_HANDY__PATHS__HERMES_BIN/usr/local/bin/hermesHermes CLI; Hermes installs only, not in the image
paths.ffmpegPRINT_HANDY__PATHS__FFMPEGffmpegffmpeg binary or name on PATH
paths.hermes_agentPRINT_HANDY__PATHS__HERMES_AGENT/data/hermes/hermes-agentHermes agent checkout (Hermes installs only)
paths.hermes_restart_helperPRINT_HANDY__PATHS__HERMES_RESTART_HELPER/data/hermes/restart_hermes.pyHermes restart helper (Hermes installs only)
services.moonraker_urlPRINT_HANDY__SERVICES__MOONRAKER_URLemptyKlipper printers: Moonraker HTTP URL, e.g. http://printer.example:7125. Required by the listener
services.moonraker_ws_urlPRINT_HANDY__SERVICES__MOONRAKER_WS_URLemptyMoonraker websocket; derived from moonraker_url when empty
services.camera_sourcePRINT_HANDY__SERVICES__CAMERA_SOURCEautoWhich camera still images come from (#294): auto follows the printer vendor's rule, or pin moonraker, bambu, go2rtc, or none
services.camera_snapshot_urlPRINT_HANDY__SERVICES__CAMERA_SNAPSHOT_URLemptyCamera snapshot URL; derived from the Moonraker host when empty
services.spoolman_urlPRINT_HANDY__SERVICES__SPOOLMAN_URLemptySpoolman URL. The Docker config and Compose default to http://127.0.0.1:8000, the bundled Spoolman API in the same container. Set another URL, plus PRINT_HANDY_BUNDLED_SPOOLMAN=false, to use an existing external Spoolman instead
services.openviking_urlPRINT_HANDY__SERVICES__OPENVIKING_URLemptyOptional OpenViking memory store
services.vision_chat_urlPRINT_HANDY__SERVICES__VISION_CHAT_URLemptyOpenAI-compatible endpoint for camera checks and roasts; empty disables both
services.stock_api_portPRINT_HANDY__SERVICES__STOCK_API_PORT8898Flashforge stock API port for IFS presence
services.octoprint_urlPRINT_HANDY__SERVICES__OCTOPRINT_URLemptyOctoPrint printers (octoprint.marlin): OctoPrint URL. API key: OCTOPRINT_API_KEY secret
discord.chat_idPRINT_HANDY__DISCORD__CHAT_IDemptyDiscord channel ID
discord.approval_user_idPRINT_HANDY__DISCORD__APPROVAL_USER_IDemptyDiscord user ID allowed to approve
discord.upload_max_bytesPRINT_HANDY__DISCORD__UPLOAD_MAX_BYTES9500000Largest upload per file on Discord: video is shrunk to it, and a sliced file or Bambu package over it is reported with its host path instead of offered for sending (see PRINTER-PROFILES.md). Keep it under your server's Discord cap (10 MB unboosted). This row governs Discord only — Telegram and Matrix posts are sized to their own upload_max_bytes rows below
discord.post_print_quiet_sPRINT_HANDY__DISCORD__POST_PRINT_QUIET_S7200Retired (#524): the bot no longer resets chat sessions after prints. Kept in the schema so existing configs still load; session resets are the agent host's own setting
discord.daily_reset_hourPRINT_HANDY__DISCORD__DAILY_RESET_HOUR4Retired (#524): see post_print_quiet_s. The shipped Hermes profile template still resets its sessions daily at 4 AM on its own config
discord.timezonePRINT_HANDY__DISCORD__TIMEZONEAmerica/New_YorkDisplay and reset time zone (IANA name)
discord.bot_namePRINT_HANDY__DISCORD__BOT_NAMEPrint HandyName the bot uses for itself
discord.question_timeout_sPRINT_HANDY__DISCORD__QUESTION_TIMEOUT_S1800How long a Discord question waits for a button
discord.safety_confirm_timeout_sPRINT_HANDY__DISCORD__SAFETY_CONFIRM_TIMEOUT_S600How long a safety confirmation waits
discord.upload_wait_sPRINT_HANDY__DISCORD__UPLOAD_WAIT_S3600How long an upload-wait freeze stays active
discord.cold_pull_outcome_sPRINT_HANDY__DISCORD__COLD_PULL_OUTCOME_S1800How long a cold-pull outcome prompt stays open
discord.token_envPRINT_HANDY__DISCORD__TOKEN_ENVDISCORD_BOT_TOKENName of the environment variable holding the bot token
discord.allowed_user_idsPRINT_HANDY__DISCORD__ALLOWED_USER_IDSemptyOptional whitelist of Discord user IDs for the standalone bot, comma or space separated. Empty (the default) lets everyone in the print channel use the bot — the channel is the access boundary (#386)
discord.denied_user_idsPRINT_HANDY__DISCORD__DENIED_USER_IDSemptyOptional deny list of Discord user IDs, comma or space separated. A denied user is refused even when whitelisted or the approval user; a refusal is logged at INFO and answered in channel once per user per hour. Applies to bot messages, commands and button presses; the live camera Activity keeps its own allowlist (approval_user_id plus allowed_user_ids)
discord.chat_llm_enabledPRINT_HANDY__DISCORD__CHAT_LLM_ENABLEDtrueDiscord's chat-LLM switch (#466). false runs Discord as a pure bot: no stray-chat model call of any kind (no [llm] call, no [agent] lane call), unmatched messages get the short not-understood reply, even while [llm] stays configured for /print-design (which inherits it). The roast and the camera watchdog use the separate vision endpoint and their own switches, so they are unaffected. bot check names this switch on its llm and agent rows when it is off. The switch means the same thing in Hermes mode (#524 stage 3): a final plugin hook claims unmatched Discord text for the bot's own lanes — with the switch off the Hermes profile agent never sees the text, and with a lane configured the bot's lane answers instead of the in-process agent; with the switch on and no lane configured, the profile agent keeps answering as before
discord.clarify_uiPRINT_HANDY__DISCORD__CLARIFY_UIfalseWho draws Print Handy's questions on a Hermes install (#524 stage 2). false (the default): Hermes' adapter renders them, exactly as before. true: Print Handy activates its own prompt registry and posts questions itself by direct Discord REST, resolving clicks and typed answers itself — one renderer in both modes, matching the standalone rendering and the README mockups. The standalone bot always uses its own renderer either way. Read when the plugin registers; restart the gateway to change it. bot check reports the renderer on its clarify ui row
telegram.token_envPRINT_HANDY__TELEGRAM__TOKEN_ENVTELEGRAM_BOT_TOKENName of the environment variable holding the Telegram bot token
telegram.chat_idPRINT_HANDY__TELEGRAM__CHAT_IDemptyTelegram chat (group) ID; group and channel IDs are negative
telegram.allowed_user_idsPRINT_HANDY__TELEGRAM__ALLOWED_USER_IDSemptyTelegram user IDs allowed to use the standalone bot, comma or space separated
telegram.approval_user_idPRINT_HANDY__TELEGRAM__APPROVAL_USER_IDemptyTelegram user ID allowed to approve
telegram.upload_max_bytesPRINT_HANDY__TELEGRAM__UPLOAD_MAX_BYTES50000000Largest file the Telegram bot uploads (the Bot API cloud limit); timelapse video is shrunk to this value, not to Discord's smaller cap
matrix.homeserver_urlPRINT_HANDY__MATRIX__HOMESERVER_URLemptyMatrix homeserver base URL, for example https://matrix.org or a self-hosted server
matrix.access_token_envPRINT_HANDY__MATRIX__ACCESS_TOKEN_ENVMATRIX_ACCESS_TOKENName of the environment variable holding the Matrix access token
matrix.room_idPRINT_HANDY__MATRIX__ROOM_IDemptyMatrix room ID of the print room (starts with !); the room must be unencrypted
matrix.allowed_user_idsPRINT_HANDY__MATRIX__ALLOWED_USER_IDSemptyMatrix user IDs allowed to use the standalone bot, full @user:server form, comma or space separated
matrix.approval_user_idPRINT_HANDY__MATRIX__APPROVAL_USER_IDemptyMatrix user ID allowed to approve
matrix.bot_user_idPRINT_HANDY__MATRIX__BOT_USER_IDemptyThe bot's own Matrix user ID; empty asks the homeserver (whoami) at startup
matrix.upload_max_bytesPRINT_HANDY__MATRIX__UPLOAD_MAX_BYTES50000000Largest file the Matrix bot uploads; lowered to the homeserver's media limit when the server reports a smaller one. Timelapse video is shrunk to that resulting limit; if even the smallest shrink cannot fit it, the completion post says the timelapse was recorded but is too large for the server instead of attaching the video
bot.modePRINT_HANDY__BOT__MODEstandalonestandalone or hermes; an explicit hermes in an existing config is unchanged — only an unset mode defaults to standalone (#524)
bot.platformPRINT_HANDY__BOT__PLATFORMdiscordChat platform for the standalone bot (#164): discord, telegram or matrix
bot.health_portPRINT_HANDY__BOT__HEALTH_PORT0Serve GET /healthz on this port; 0 turns the HTTP check off
bot.health_stale_sPRINT_HANDY__BOT__HEALTH_STALE_S120Bot health fails when its heartbeat is older than this
bot.sync_commandsPRINT_HANDY__BOT__SYNC_COMMANDStrueRegister the slash commands with Discord at startup
llm.chat_urlPRINT_HANDY__LLM__CHAT_URLemptyOptional OpenAI-compatible chat URL; empty: unmatched text gets the command list
llm.modelPRINT_HANDY__LLM__MODELemptyLLM model name, required when chat_url is set
llm.api_key_envPRINT_HANDY__LLM__API_KEY_ENVLLM_API_KEYName of the environment variable holding the LLM API key
llm.timeout_sPRINT_HANDY__LLM__TIMEOUT_S20LLM request timeout
agent.chat_urlPRINT_HANDY__AGENT__CHAT_URLemptyStray-chat agent lane (#524 stage 3): an agent endpoint — any OpenAI-compatible chat completions URL, e.g. a Hermes agent behind its API — answers Discord free text the handlers did not take, ahead of the [llm] call. Empty: no agent lane; stray chat uses [llm] if configured, else the short not-understood reply. The endpoint answers in its own voice; its replies are posted as-is and can never run a command. discord.chat_llm_enabled=false turns this lane off too
agent.modelPRINT_HANDY__AGENT__MODELemptyAgent model name; optional, unlike llm.model — empty omits model from the request, for endpoints that are not model-addressable
agent.api_key_envPRINT_HANDY__AGENT__API_KEY_ENVAGENT_API_KEYName of the environment variable holding the agent endpoint's API key
agent.timeout_sPRINT_HANDY__AGENT__TIMEOUT_S120Agent request timeout; one attempt, no retries. Longer than llm.timeout_s because an agent turn does real work
design.enabledPRINT_HANDY__DESIGN__ENABLEDunset (auto)/print-design / /design AI design (#388). Unset = auto: on whenever a design/LLM endpoint is configured (like roasts); false forces off (the command answers "not set up"); true forces on (an unconfigured endpoint is then reported, not silent)
design.chat_urlPRINT_HANDY__DESIGN__CHAT_URLempty (inherit)Design model endpoint; empty uses llm.chat_url. Wins in Hermes mode too — the profile's auxiliary.print_design block is the fallback only when no endpoint is configured
design.modelPRINT_HANDY__DESIGN__MODELempty (inherit)Design model name; empty uses llm.model. Point this at a stronger model than chat if the endpoint offers one
design.api_key_envPRINT_HANDY__DESIGN__API_KEY_ENVempty (inherit)Environment variable holding the design endpoint's key; empty uses llm.api_key_env
design.timeout_sPRINT_HANDY__DESIGN__TIMEOUT_S180Design model-call timeout in seconds; also the OpenSCAD render wall-clock limit
design.repair_attemptsPRINT_HANDY__DESIGN__REPAIR_ATTEMPTS3How many times a failed render is sent back to the model with the error before the turn gives up
listener.tick_sPRINT_HANDY__LISTENER__TICK_S600Listener housekeeping interval
listener.ifs_presence_poll_sPRINT_HANDY__LISTENER__IFS_PRESENCE_POLL_S15IFS spool presence poll
listener.spool_nag_sPRINT_HANDY__LISTENER__SPOOL_NAG_S600Reminder interval for an unanswered spool question
listener.start_claim_ttl_sPRINT_HANDY__LISTENER__START_CLAIM_TTL_S1800How long a start button stays claimed
vision.modelPRINT_HANDY__VISION__MODELgemini-latestModel for camera checks
vision.roast_modelPRINT_HANDY__VISION__ROAST_MODELluna-latestModel for the print-start roast
vision.roast_enabledPRINT_HANDY__VISION__ROAST_ENABLEDtrueThe one-line roast on the print-started post only (never on the completion post), on every platform (Telegram included). On by default: it runs whenever an LLM/vision endpoint is available (services.vision_chat_url plus CLIPROXY_API_KEY); false turns roasts off without disabling camera checks
vision.auto_light_off_minutesPRINT_HANDY__VISION__AUTO_LIGHT_OFF_MINUTES0Auto light-off (#516) for Moonraker, Bambu Lab and Elegoo SDCP printers: 0 is off (the default). When set (30 is the suggested value), the light turns on at print start and off again after the plate has been seen empty for that many minutes. Needs a vision endpoint, like the camera watchdog; on SDCP the turn-on works but auto-off waits on a verified camera source; see Auto light-off
vision.light_objectPRINT_HANDY__VISION__LIGHT_OBJECTemptyThe Moonraker light object the auto light-off switches: led <name>, neopixel <name> or output_pin <name> (for example led chamber_led). Empty auto-discovers: the printer's light object is used only when it lists exactly one; with several, the listener logs the candidates and the feature stays off until one is named here
visual_watch.sensitivityPRINT_HANDY__VISUAL_WATCH__SENSITIVITY3Camera watchdog sensitivity, 1 (quietest) to 5 (most alerts); also sets the periodic check interval and stall threshold, see the project reference
visual_watch.enabledPRINT_HANDY__VISUAL_WATCH__ENABLEDtrueCamera monitoring switch; the watchdog is alert-only and never controls the printer
timelapse.modePRINT_HANDY__TIMELAPSE__MODEderivedTimelapse mode (#246/#295): rendered (the printer renders it, moonraker-timelapse), container (in-container capture) or off. Empty in a file derives: rendered when a Moonraker URL is configured, else off
timelapse.encoderPRINT_HANDY__TIMELAPSE__ENCODERcpuffmpeg encoder for the timelapse sizing shrink (#314): cpu (libx264), vaapi, qsv or nvenc. Hardware choices need the GPU passed through to the container (see DOCKER.md); a hardware encode that fails falls back to cpu automatically, so a completion post is never lost to the encoder
timelapse.archive_hostPRINT_HANDY__TIMELAPSE__ARCHIVE_HOSTemptySSH host for the timelapse archive; empty disables it
timelapse.archive_dirPRINT_HANDY__TIMELAPSE__ARCHIVE_DIRemptyArchive folder on that host
timelapse.ssh_keyPRINT_HANDY__TIMELAPSE__SSH_KEYemptyPath of the SSH key in the container (mount it read-only)
timelapse.render_wait_sPRINT_HANDY__TIMELAPSE__RENDER_WAIT_S900Wait for the printer to render the timelapse. Delivery contract (#373): when archiving is on and the rendered video doesn't arrive within the wait (or the marker can't be matched to the job), the completion post itself carries a "Timelapse not attached" line with the reason instead of the video vanishing silently; and if the listener restarts during the wait, the pending completion re-drives the retrieval for the persisted job rather than posting the text without the video
timelapse.render_poll_sPRINT_HANDY__TIMELAPSE__RENDER_POLL_S10Poll interval while waiting
timelapse.max_bytesPRINT_HANDY__TIMELAPSE__MAX_BYTES209715200Largest timelapse kept
timelapse.local_retention_daysPRINT_HANDY__TIMELAPSE__LOCAL_RETENTION_DAYS1Local copy retention
timelapse.plan_clip_retention_daysPRINT_HANDY__TIMELAPSE__PLAN_CLIP_RETENTION_DAYS14Plan clip retention
timelapse.vertical_shortPRINT_HANDY__TIMELAPSE__VERTICAL_SHORTfalseAlso post a 9:16 vertical version of each finished timelapse (#317), alongside the timelapse itself
timelapse.vertical_short_modePRINT_HANDY__TIMELAPSE__VERTICAL_SHORT_MODEpadpad fills the 9:16 frame with a blurred background; crop centre-crops
timelapse.vertical_short_overlayPRINT_HANDY__TIMELAPSE__VERTICAL_SHORT_OVERLAYtrueBurn the print title / printer / filament / time overlay into the vertical short
timelapse.capture_enabledPRINT_HANDY__TIMELAPSE__CAPTURE_ENABLEDfalseIn-container timelapse capture (#246): starts the bundled go2rtc (full Docker image only) to normalise the camera source on loopback. Off keeps today's printer-rendered behaviour. On stock Flashforge there is no go2rtc leg: the launched-print monitor captures from the camera source the printer advertises in /detail (see FLASHFORGE-STOCK.md)
timelapse.capture_source_urlPRINT_HANDY__TIMELAPSE__CAPTURE_SOURCE_URLemptyExplicit capture camera source (MJPEG/RTSP URL or a go2rtc ffmpeg: source). Empty derives it from services.camera_snapshot_url / the Moonraker webcam
timelapse.capture_triggerPRINT_HANDY__TIMELAPSE__CAPTURE_TRIGGERintervalStage 2 frame capture trigger (#246): interval (a frame every capture_interval_s), layer (a frame on each Moonraker layer change) or both
timelapse.capture_interval_sPRINT_HANDY__TIMELAPSE__CAPTURE_INTERVAL_S5.0Seconds between captured frames for the interval trigger (above 0, at most 3600)
timelapse.capture_max_framesPRINT_HANDY__TIMELAPSE__CAPTURE_MAX_FRAMES3600Per-job cap on captured frames; capture stops at the cap and keeps the frames already taken. 0 disables the cap
timelapse.capture_max_bytesPRINT_HANDY__TIMELAPSE__CAPTURE_MAX_BYTES536870912Per-job cap on captured frame bytes; capture stops at the cap and keeps the frames already taken. 0 disables the cap
timelapse.capture_encoderPRINT_HANDY__TIMELAPSE__CAPTURE_ENCODERautoEncoder preference for the Stage 3 assembly encode (#246): auto (VAAPI → NVENC → QSV → CPU), vaapi, nvenc, qsv or cpu. A failing or unavailable hardware encoder falls back to CPU; GPU paths are not verified on real hardware
webui.enabledPRINT_HANDY__WEBUI__ENABLEDfalseOptional Web UI (#286) switch; off by default, and nothing listens while disabled
webui.hostPRINT_HANDY__WEBUI__HOST127.0.0.1Host the Web UI binds to when enabled; a plain IP address or hostname
webui.portPRINT_HANDY__WEBUI__PORT0Port the Web UI binds to when enabled; 0 means no port has been chosen yet
webui.access_token_envPRINT_HANDY__WEBUI__ACCESS_TOKEN_ENVWEBUI_ACCESS_TOKENName of the environment variable holding the optional Web UI access token; the token itself is never a setting
live_stream.enabledPRINT_HANDY__LIVE_STREAM__ENABLEDfalseLive camera viewing in Discord (#311) switch; off by default, and /print-live only reports that while disabled
live_stream.voice_channel_idPRINT_HANDY__LIVE_STREAM__VOICE_CHANNEL_IDemptyThe one Discord voice channel the live Activity is launched in; the bot never names another channel. Keep it a private channel (see DISCORD-BOT-SETUP.md)
live_stream.auto_start_on_printPRINT_HANDY__LIVE_STREAM__AUTO_START_ON_PRINTfalseOffer the live view automatically when a print starts; the viewing session itself still starts only from Discord
live_stream.max_heightPRINT_HANDY__LIVE_STREAM__MAX_HEIGHT720Live feed height cap in pixels (144-2160); width follows the camera's aspect
live_stream.fpsPRINT_HANDY__LIVE_STREAM__FPS15Live feed frame-rate cap (1-60)
live_stream.max_bitrate_kbpsPRINT_HANDY__LIVE_STREAM__MAX_BITRATE_KBPS2500Live feed bitrate cap in kbit/s (100-20000)
live_stream.encoderPRINT_HANDY__LIVE_STREAM__ENCODERautoEncoder preference for the live feed: auto, vaapi, qsv, nvenc or cpu (the plan in print_handy/live_stream.py)
live_stream.empty_timeout_sPRINT_HANDY__LIVE_STREAM__EMPTY_TIMEOUT_S300Seconds the voice channel may stay empty of viewers before the live session ends itself
live_stream.activity_originPRINT_HANDY__LIVE_STREAM__ACTIVITY_ORIGINemptyPublic https origin the Discord Activity page is served from: the named Cloudflare Tunnel's hostname, mapped in the Discord Developer Portal (see DISCORD-BOT-SETUP.md); empty until deployed
live_stream.portPRINT_HANDY__LIVE_STREAM__PORT0Loopback port the supervised Activity server binds inside the deployment; 0 means no port has been chosen yet, so the server does not start
logging.levelPRINT_HANDY__LOGGING__LEVELINFODEBUG, INFO, WARNING or ERROR
logging.formatPRINT_HANDY__LOGGING__FORMATtexttext (one readable line per event) or json (one JSON object per line)
logging.filePRINT_HANDY__LOGGING__FILEemptyAlso write a rotating log file (5 MiB, 3 backups) at this path, for example under /data/hermes/logs; empty logs to stderr only
logging.levelsfile onlyemptyPer-subsystem verbosity (#390): a table of logger name to level, for example levels = { "print_handy.core.orca_adapter" = "DEBUG" } in TOML, so one subsystem can be raised without a wall of debug output; applied at process start
maintenance.quick_hoursPRINT_HANDY__MAINTENANCE__QUICK_HOURS25Quick maintenance due after these print hours
maintenance.full_hoursPRINT_HANDY__MAINTENANCE__FULL_HOURS75Full maintenance due after these print hours
maintenance.full_daysPRINT_HANDY__MAINTENANCE__FULL_DAYS30Full maintenance due after these days
nozzle_temp_offsets.<type>PRINT_HANDY__NOZZLE_TEMP_OFFSETS__<TYPE>per type: hardened_steel and stainless_steel 5.0, others 0.0Temperature offset, −10 to +15 °C, for brass, hardened_steel, stainless_steel, ruby, tungsten_carbide, diamond. The steel defaults compensate for lower thermal conductivity and apply on top of every filament's baseline temperature (all materials, not one); set a type here to override its default
material_temps.<MATERIAL>file onlysee config/home.yaml{nozzle, bed} fallback temperatures for PLA, PETG, ABS, ASA, TPU, PA, PC; Spoolman filament values win
spool.prompt_brandPRINT_HANDY__SPOOL__PROMPT_BRANDfalseAsk for the brand/vendor during the conversational new-spool walkthrough (#512). Off by default: the walkthrough skips brand entirely and the spool is created without one. Set true only if you track brands

Material variant adjustments (#512). A named variant within a family — Silk PLA, PLA+, Matte PLA, and the fibre-reinforced *-CF / *-GF variants of any family — adjusts the family fallback above: Silk PLA +10 °C nozzle and 0.7× outer-wall speed when slicing (its gloss needs the slower wall), PLA+ / Matte PLA / reinforced variants +5 °C nozzle (PA-CF +10 °C), Matte PLA 0.9× outer-wall speed. The adjustment applies only to the material-default fallback: a temperature recorded on the Spoolman filament itself is explicit data and is never adjusted, and plain family labels (PLA, PETG, ABS, ASA, TPU, PA, PC) are never adjusted.

Photo label intake (#514). When any label-reading route is available, the new-spool walkthrough's first step also offers Use a photo of the label. The operator sends one photo of the spool's box or sticker as the next message in the channel, and the routes are tried in a fixed order: (1) the label's own barcode/QR code, decoded locally with the zxing-cpp library (a runtime dependency) — deterministic data the manufacturer printed; (2) the vision endpoint — the same availability rule the camera watchdog uses: PRINT_VISION_URL set, or services.vision_chat_url with its key; (3) local OCR with the tesseract binary (installed in the shipped container image; elsewhere, install tesseract-ocr to enable it), matched against the known filament types and the material/colour/brand vocabulary labels are printed from. The first route that reads a complete spec wins; partial results are never merged across routes. Every route extracts the same fields — brand, material (with sub-type), colour/blend, net weight and, when printed, the recommended nozzle/bed temperatures — and the values pre-fill the walkthrough at its confirmation step, which names the route that read them. Nothing is created from a photo without that confirmation, and Edit details walks the normal steps instead. A photo no route can use refuses with the reason and falls back to the manual steps. Label temperatures are recorded on the Spoolman filament the spool is created with, so they win over material defaults exactly like temperatures entered in Spoolman itself, for every material; a filament type that already exists keeps the temperatures it already has. The photo itself is never stored or recorded: it goes only to the one route that reads it (a local decoder, a local binary, or the configured vision endpoint). With no route available the button is not offered and the walkthrough is unchanged. The guided /spool flow does not offer the photo step: its gateway prompts answer with buttons or text and cannot carry an attachment. | cold_pull.<profile> | file only | from the vendor profile model | Hot and cold cold-pull temperatures; profile names are yours |

Custom vision endpoint (PRINT_VISION_URL) ​

The camera watchdog normally judges frames through the OpenAI-compatible services.vision_chat_url endpoint (with CLIPROXY_API_KEY). Setting the plain environment variable PRINT_VISION_URL — it is not a PRINT_HANDY__ setting and has no config-file form — points the watchdog at your own detector instead: a Frigate-adjacent sidecar, an Obico-style adapter, a small ONNX service, anything that can answer one HTTP call. When it is set, it is the route used for every camera check: the OpenAI-compatible route is not consulted, no API key is needed, and the camera watch counts as having a vision endpoint. It serves camera checks and spool-label photo reads (#514) — the print-start roast still needs services.vision_chat_url.

The contract is deliberately small:

  • Request: POST to the PRINT_VISION_URL URL with header Content-Type: application/json and no Authorization header. The body is a JSON object: {"image": "<base64>", "prompt": "<text>"}. image is the camera frame's JPEG bytes, standard base64, no data: prefix. prompt is the watchdog's question text (today: "Is there plastic on the bed under the nozzle, or is there spaghetti/detachment?"). Unlike the OpenAI-compatible route, no format instructions are prepended — the endpoint itself maps its analysis onto the verdict vocabulary below.
  • Response: HTTP 200 with a JSON object body: {"verdict": "...", "reason": "..."}. verdict is matched case-insensitively (Print Handy lowercases it). The vocabulary is ok, air, spaghetti, detached, empty_idle, uncertain; the watch also recognises safe (healthy, like ok) and no / unsafe (suspicious). A missing verdict counts as uncertain; any other value is passed through and matches neither the alert nor the healthy set. reason is free text shown with the alert; a missing one counts as empty, and it is truncated to 300 characters.
  • How verdicts map: ok / safe are healthy (at visual_watch.sensitivity 1–2 a healthy verdict can veto a pixel-diff stall alert). air, spaghetti and detached alert. uncertain also alerts — a frame that cannot be judged is treated as needing review, never as a pass. empty_idle is neither: an idle printer with an empty bed is expected.
  • Timeout and failures: Print Handy waits at most 30 seconds for the answer. Anything that goes wrong — endpoint unreachable, timeout, an HTTP error status, or a body that is not a JSON object — degrades to verdict uncertain with the detail "the vision check could not be completed", so a broken endpoint produces visible alerts instead of silently passing frames. The underlying error is written to the log (vision check failed via PRINT_VISION_URL: ...), which is where a mis-configured URL is diagnosed.
  • Label reads (#514): the same endpoint may also receive the spool-label prompt (the photo intake above): the request shape is identical, the image is the operator's label photo, and the response body should be the JSON object the prompt describes (brand / material / color / grams / nozzle_temp / bed_temp) instead of a verdict object. An endpoint that only answers verdicts will simply fail the label read — the walkthrough refuses with the reason and falls back to its manual steps.

Auto light-off ​

vision.auto_light_off_minutes (#516) is an opt-in for Moonraker, Bambu Lab and Elegoo SDCP printers: the printer's light turns on when a print starts, and off again once the plate has been seen empty for that many minutes after a print ends. It is off by default (0); 30 is the suggested value. It is the one deliberate exception to the camera watchdog's alert-only posture (#455), and it extends to exactly one accessory — the light. It never touches motion, heaters, pause/stop or a print in progress. The other families (PrusaLink, OctoPrint, stock FlashForge, offline) have no light to switch: for those printers the setting has no effect and bot check says so.

How it works:

  • On is a state event, never a vision call. The listener's print-start transition sends the on command. That has to be so: once the light is off the camera frame is dark, so vision could never be the on-trigger.
  • Off needs both a state gate and sustained vision. After a print reaches a terminal state, an idle window opens. While the printer stays idle, the plate is checked about every 5 minutes through the same camera snapshot and verdict path as the watchdog. Only verdicts of empty_idle, sustained across the full configured minutes, turn the light off. Any other verdict resets the wait — uncertain included, so darkness or ambiguity never turns a light off, and a part left on the plate keeps the light on indefinitely (there is no long-idle override). A new print cancels the window. After lights-out, checking stops until the next print.
  • A verdict endpoint is required — the same gate as the camera watchdog (services.vision_chat_url plus its key, or PRINT_VISION_URL). With none configured the feature stays off, and bot check says so on its auto light row.
  • The light target is discovered, never hardcoded. On Moonraker, the listener reads the printer's object list and considers led, neopixel and output_pin objects. Name one explicitly with vision.light_object (for example led chamber_led); with the setting empty, auto-discovery acts only when the printer lists exactly one candidate — with several, the listener logs the candidate names once and the feature stays off rather than guessing which light is "the" light.
  • On Bambu Lab the target is the chamber light — a fixed protocol target (MQTT system/ledctrl, led_node: chamber_light), so vision.light_object does not apply. Whether a model has one is read from the printer's own telemetry (lights_report): a model that reports no chamber light is never switched, and the listener logs the feature as unavailable for it once.
  • On Elegoo SDCP the target is the chamber light too (Cmd 403, LightStatus.SecondLight), and vision.light_object does not apply. Print-start turn-on works, but auto-off cannot run on SDCP yet: no SDCP camera source is verified (see ELEGOO.md), so the idle empty-plate check has no frame to judge and the light stays on after a print until it is switched some other way. bot check names this on its auto light row rather than letting the setting look fully on.
  • Switching on Moonraker happens in Moonraker (SET_LED / SET_PIN through Moonraker's G-code endpoint). Home Assistant's Moonraker integration exposes the same light, so its state in Home Assistant follows automatically — Print Handy does not talk to Home Assistant for this. Flipping the light in Home Assistant is just a Moonraker state change like any other; the automation never polls or fights it — it acts only at print start and at the end of an empty window.
  • Failures are quiet and final for that transition. A failed light command is logged once and never blocks, delays or fails a print.

Printers list ​

By default Print Handy runs one printer, described by the [printer], [services], [bambu], [prusa], [elegoo_sdcp] and [flashforge_stock] settings above. A config file may instead declare a list of printers — printers: in YAML, [[printers]] in TOML — one entry per printer, each carrying its own connection keys (#333; design: MULTI-PRINTER-DESIGN.md). The list itself is file only: it cannot come from the environment.

The first entry is the default printer: the legacy single-printer settings are populated from it, so everything that reads [printer], the [services] URLs or a vendor section sees the first entry. When a list is declared, the legacy per-printer locations must be absent or empty in the same file and in the environment — setting both is refused at startup, with the offending locations named. A config with no list behaves exactly as before: loading synthesises a one-entry roster from the legacy settings, its id the slug of printer.name (or printer when there is no name).

Each entry is validated against its own model with the same rules as the legacy sections: a Bambu entry may not carry a Moonraker URL, lan_host and lan_serial come as a pair, a host belongs only to Elegoo SDCP and stock Flashforge entries, and so on. Two entries may not share an id, and two entries may not claim the same chat_id (an entry without one falls back to discord.chat_id).

Scalar keys of a declared entry can be overridden from the environment with the indexed spelling PRINT_HANDY__PRINTERS__<ID>__<KEY> — the entry's id uppercased in the <ID> position, the key in the <KEY> position. The environment can never create an entry, and id and model cannot be overridden: they are the entry's identity in the file.

Each printer's runtime state lives in its own folder, state/printers/<id>/ under the profile's state directory (#334): its hardware record (printer_hardware.json), its launch database (jobs.db — the print lease and consumed bed-clear approvals), and its maintenance record (maintenance.json). Job folders stay in the shared archive; a job's JOB.json records the printer_id it belongs to. On the first start after upgrading, the default printer adopts the previous root-level state files — a held lease and the hardware record carry over intact, new writes go to the per-printer folder, and the old files are left in place for one release so a rollback still finds them.

Channel routing (#335). Each printer is driven from its own channel. An entry's chat_id is that printer's channel on whichever platform the bot runs — a Discord channel ID, a Telegram chat ID, or a Matrix room ID — and an entry without one uses the platform's global chat (discord.chat_id, telegram.chat_id or matrix.room_id), which is therefore the default printer's channel unless the default entry sets its own. A message, command or button press in a printer's channel is handled for that printer end to end: intake stamps the job with that printer, status, stop, resume, spool, macro and maintenance commands read and act on that printer, and its prompts are answered only in its own channel. A channel no printer claims gets silence — there is no fallback to the default printer for unmapped channels, because an approval button carries no typed target: the channel is the target, and the default printer is named in output wherever a refusal or listing stands in for it. Two entries claiming one channel refuse startup with the channel and both printers named (the settings load already refuses the Discord case; the bot re-checks for the platform it runs). Delivery methods stay global in v1; they are not per-printer.

Listeners (#336). Every printer whose connection needs the Moonraker listener gets its own listener instance — one child process per printer under the Docker supervisor, one runner per printer in the native service — started with that printer's per-printer view: its Moonraker URL, its state folder, its spool and its camera. A listener that dies is restarted on its own backoff and never takes the bot or a sibling listener down; the aggregated health covers every listener, and both the bot status reply and the Web UI status page report each one.

Setting a roster up (#337). The CLI setup wizard asks "Add another printer?" after the first printer and writes the roster for you, and the in-chat guided setup offers the same at its review step (reply add). Both refuse to guess at a config they cannot rewrite faithfully: the wizard only rewrites an existing config/local.yaml it can parse and re-emit unchanged-in-meaning, and leaves an un-round-trippable file byte-for-byte untouched with instructions instead. The Web UI does not edit the roster in this version — its field catalog addresses global fields only — but the status page lists the roster read-only (id, model, connection, channel), and /api/status carries the same rows under printers.

YAML keyEnvironment variableDocker valueWhat it does
printers.idfile only—Required. The printer's stable identity: a slug of a-z, 0-9, - and _ starting with a letter or digit, unique in the list. Renaming an id makes it a different printer as far as its state is concerned
printers.modelfile only—Required. A registry model key, spelled exactly as printer.model
printers.namePRINT_HANDY__PRINTERS__<ID>__NAME—Display name for this printer; defaults to the name the model's own defaults give printer.name
printers.chat_idPRINT_HANDY__PRINTERS__<ID>__CHAT_ID—This printer's channel on the platform the bot runs (Discord channel ID, Telegram chat ID, or Matrix room ID); empty falls back to that platform's global chat. Two entries may not claim the same channel
printers.moonraker_urlPRINT_HANDY__PRINTERS__<ID>__MOONRAKER_URL—This entry's services.moonraker_url; must be empty for offline, Bambu LAN, stock Flashforge, PrusaLink and SDCP models
printers.moonraker_ws_urlPRINT_HANDY__PRINTERS__<ID>__MOONRAKER_WS_URL—This entry's services.moonraker_ws_url; derived from moonraker_url when empty
printers.octoprint_urlPRINT_HANDY__PRINTERS__<ID>__OCTOPRINT_URL—This entry's services.octoprint_url, for OctoPrint models
printers.camera_snapshot_urlPRINT_HANDY__PRINTERS__<ID>__CAMERA_SNAPSHOT_URL—This entry's services.camera_snapshot_url; derived from moonraker_url when empty
printers.lan_hostPRINT_HANDY__PRINTERS__<ID>__LAN_HOST—This entry's bambu.lan_host (Bambu Lab models only); set together with lan_serial
printers.lan_serialPRINT_HANDY__PRINTERS__<ID>__LAN_SERIAL—This entry's bambu.lan_serial (Bambu Lab models only); set together with lan_host
printers.lan_access_code_envPRINT_HANDY__PRINTERS__<ID>__LAN_ACCESS_CODE_ENV—This entry's bambu.lan_access_code_env: the environment variable holding the printer's LAN access code
printers.start_confirm_sPRINT_HANDY__PRINTERS__<ID>__START_CONFIRM_S—This entry's bambu.start_confirm_s
printers.link_hostPRINT_HANDY__PRINTERS__<ID>__LINK_HOST—This entry's prusa.link_host (Prusa models only)
printers.api_key_envPRINT_HANDY__PRINTERS__<ID>__API_KEY_ENV—This entry's prusa.api_key_env: the environment variable holding the PrusaLink API key
printers.hostPRINT_HANDY__PRINTERS__<ID>__HOST—This entry's elegoo_sdcp.host or flashforge_stock.host, chosen by the entry's model; no other model takes a host
printers.nozzle_typePRINT_HANDY__PRINTERS__<ID>__NOZZLE_TYPE—This entry's printer.nozzle_type override, limited to the model's supported types
printers.tool_countPRINT_HANDY__PRINTERS__<ID>__TOOL_COUNT—This entry's printer.tool_count override, for multi-tool models

Compose variables ​

These configure the Compose deployment, not Print Handy itself. Set them in .env.

VariableDefaultWhat it does
PRINT_HANDY_IMAGEnone, requiredImage the combined container runs: a locally built tag or a tested immutable digest. No published image exists yet
PRINT_HANDY_DATA./dataHost folder holding the hermes, archive, records and cache volumes
PRINT_HANDY_SPOOLMAN_DATA./data/spoolmanHost folder for the bundled Spoolman database, config, logs and backups
PRINT_HANDY_CONFIG/app/config/docker.yamlSettings file Print Handy reads (YAML, or legacy .toml)
DISCORD_CLIENT_IDemptyThe Discord application's ID for the live view (#311): a plain identifier (it appears in invite URLs), not a secret. The Activity server refuses to start without it when live viewing is enabled
CLOUDFLARED_IMAGEnone, required for the sidecarImage the optional cloudflared sidecar runs (Compose profile live-stream): a locally tested tag or immutable digest of the Cloudflare Tunnel connector
TZUTCContainer timezone (IANA name), used by the bundled Spoolman; Print Handy itself uses discord.timezone

Runtime environment ​

VariableDefaultWhat it does
PRINT_HANDY_ORCA_CHANNELstableSelects the bundled stable or alpha OrcaSlicer at startup; invalid values stop the container
PRINT_HANDY_BUNDLED_SPOOLMANtruetrue runs the bundled Spoolman API; false uses services.spoolman_url only
SPOOLMAN_HOST127.0.0.1Bundled Spoolman bind address; widen it only together with an approved port mapping, see docs/DOCKER.md
PRINT_VISION_URLemptyCustom vision endpoint for camera checks; takes precedence over services.vision_chat_url and needs no key. See Custom vision endpoint

WITH_ORCA=false (default) omits both Orca channels; slicing fails closed without either binary.

Build arguments ​

Image build inputs (docker build --build-arg NAME=value), not runtime settings. PRINT_HANDY_ORCA_CHANNEL is a runtime environment variable (default stable, accepts alpha) and requires an image built with WITH_ORCA=true.

ArgumentDefaultWhat it does
APP_CHANNELdevelopmentApplication provenance label, stable or development; it does not change the source checkout
WITH_ORCAfalsetrue installs the pinned official prebuilt OrcaSlicer release on both channels (stable and alpha both pin v2.5.0-alpha since #356, each SHA256 verified in docker/orca-prebuilt.sh); OrcaSlicer is never built from source; false omits it and slicing fails closed
WITH_GO2RTCtruetrue installs the pinned official go2rtc prebuilt (v1.9.15, SHA256 verified in docker/go2rtc-prebuilt.sh) and the VAAPI driver stack for in-container timelapse capture (#246); the published images set it; false omits both
SPOOLMAN_REFbed3f14ee77a3a571d31b14215d54b0b88a7519cPinned Spoolman v0.27.0 source commit; only its Python API backend is installed, never the web UI
ORCA_BUILD_JOBSautoBambu Studio source build only: parallel compile jobs, auto (memory-aware, below the CPU count) or a whole number from 1 to 64
ORCA_BUILD_MEM_PER_JOB_MIB2560Bambu Studio source build only: memory budgeted per job by auto, at least 256
ORCA_CCACHE_MAXSIZE5GBambu Studio source build only: size cap of the compiler cache inside the build cache
WITH_BAMBUfalsetrue builds Bambu Studio from pinned source into /usr/bin/bambu-studio; false omits it and Bambu Lab slicing fails closed
BAMBU_REF926a7192574bcb9b3a732e1ec59a46d79cb45466Pinned Bambu Studio 2.8.2 source commit
BAMBU_BUILD_PROFILErelease-static-fhsNames the Bambu Studio build options; rename it to reset the build trees

Secrets ​

Secrets come from the environment only (Docker: .env, never committed or shared), never from YAML or the image.

VariableUsed for
DISCORD_BOT_TOKENStandalone bot token. The variable name is discord.token_env
TELEGRAM_BOT_TOKENTelegram standalone bot token. The variable name is telegram.token_env
MATRIX_ACCESS_TOKENMatrix standalone bot access token. The variable name is matrix.access_token_env
WEBUI_ACCESS_TOKENOptional Web UI (#286) access token, required when it binds beyond loopback. The variable name is webui.access_token_env
DISCORD_CLIENT_SECRETLive camera in Discord (#311): the Discord application's client secret, used server-side for the Activity OAuth exchange. Never a setting
LIVE_STREAM_SESSION_SECRETLive camera in Discord (#311): signs the Activity session markers. Never a setting
TUNNEL_TOKENLive camera in Discord (#311): the named Cloudflare Tunnel's token. Read only by the optional cloudflared sidecar; Print Handy itself never uses it
LLM_API_KEYOptional key for llm.chat_url. The variable name is llm.api_key_env
AGENT_API_KEYOptional key for agent.chat_url. The variable name is agent.api_key_env
OCTOPRINT_API_KEYoctoprint.marlin printers: OctoPrint application key
BAMBU_LAN_PASSWORDBambu Lab printers: the LAN access code for read-only status. The variable name is bambu.lan_access_code_env
MOONRAKER_API_KEYMoonraker API key, only when Moonraker requires one
CLIPROXY_API_KEYKey for services.vision_chat_url
OPENVIKING_API_KEYKey for services.openviking_url
AD5X_SERIALStock Flashforge printers: the printer serial number for the stock LAN API. Never a setting; set it together with flashforge_stock.host
AD5X_CHECK_CODEStock Flashforge printers: the printer check code for the stock LAN API. Never a setting

The MakerWorld account token is not an environment variable. It is the file ~/.config/bambu/token (Docker: /data/.config/bambu/token), needed only for MakerWorld links; see MakerWorld account token.

Legacy environment names ​

The names below predate PRINT_HANDY__SECTION__KEY and still work; new installs should use the long form. The full map is LEGACY_ENV in print_handy/settings.py, for example MOONRAKER_URL → services.moonraker_url, SPOOLMAN_URL → services.spoolman_url, PRINT_DISCORD_CHAT_ID → discord.chat_id.

Documentation content is copied from catduckgnaf/print-handy — that repo's docs/ directory is the source of truth.