Skip to content

Use the CLI for Scripting

Automate scanning workflows with saneless CLI commands, JSON output, and predictable exit codes.

What you'll need

JSON output mode

saneless devices and saneless jobs support --json for machine-parseable output:

saneless devices --json
saneless jobs --json --limit 10

saneless doctor has no --json mode; it prints a table for a person to read, and its exit code says whether any check failed (see saneless doctor's exit code). To check for a scanner before a scan, ask saneless devices instead (see Checking readiness before a scan).

Device list JSON

saneless devices --json
[
  {
    "name": "net:192.168.1.50:pixma:MF740C",
    "vendor": "Canon",
    "model": "MF740C Series",
    "type": "multi-function peripheral"
  }
]

These four keys, in this order, are the whole object unless you add --capabilities. Only the JSON document is written to stdout; status lines and errors go to stderr, so you can pipe the output straight into jq.

With --capabilities, each device also carries a capabilities object:

saneless devices --json --capabilities
[
  {
    "name": "net:192.168.1.50:pixma:MF740C",
    "vendor": "Canon",
    "model": "MF740C Series",
    "type": "multi-function peripheral",
    "capabilities": {
      "sources": ["Flatbed", "ADF Simplex", "ADF Duplex"],
      "resolutions": [150, 300, 600],
      "modes": ["Color", "Gray", "Lineart"],
      "raw_options": ["source", "mode", "resolution"]
    }
  }
]

To pick out one field per device, filter it with jq:

saneless devices --json --capabilities | jq -c '.[] | {name, sources: .capabilities.sources}'
{"name":"net:192.168.1.50:pixma:MF740C","sources":["Flatbed","ADF Simplex","ADF Duplex"]}

A key appears only when the device reported something for it. A device that gives its resolution as a range has "resolution_range": {"min": ..., "max": ..., "step": ...} instead of resolutions.

If one device's capabilities cannot be read, that device gets "capabilities": null and a one-line "capabilities_error" string. The other devices are still reported and stdout is still valid JSON, but the command exits 1, so check the exit code as well as the document:

if ! caps=$(saneless devices --json --capabilities); then
  echo "$caps" | jq -r '.[] | select(.capabilities == null) | "\(.name): \(.capabilities_error)"' >&2
fi

Job history JSON

saneless jobs lists the scans the web UI ran, from the job database the server keeps. A saneless scan run is not recorded there: it reports through its own output and exit code (see Exit codes), so a script that runs saneless scan should check that, not saneless jobs.

saneless jobs --json --limit 5
[
  {
    "id": "8eae6099-6b24-4b69-a339-8daa8e6c9a5c",
    "profile": "default",
    "title": "Invoice March 2026",
    "state": "DONE",
    "created_at": "2026-03-22T14:30:00+00:00",
    "outcome": "SUCCESS",
    "warning": null,
    "error": null,
    "pages_scanned": 4,
    "pages_removed": 2,
    "pages_uploaded": 2,
    "pages_removed_positions": [2, 4]
  }
]

state is always the raw uppercase enum value — PENDING, SCANNING, AWAITING_FLIP, AWAITING_NEXT_PASS, AWAITING_BLANK_DECISION, AWAITING_RETRY, SCANNING_REVERSE, ASSEMBLING, UPLOADING, DONE, ERROR, FALLBACK or CANCELLED — so it is safe to compare against in a script. AWAITING_FLIP and SCANNING_REVERSE only occur in manual duplex jobs: the wait for the operator to flip the stack, and the second pass over the back sides. AWAITING_NEXT_PASS, AWAITING_BLANK_DECISION and AWAITING_RETRY only occur in multi-page jobs, while the job waits for the operator's answer: whether there is another page, what to do about pages that look blank, and what to do after a scan that failed. Like AWAITING_FLIP, they are not finished, but nothing happens in them until someone answers or the wait runs out. CANCELLED is a scan the operator stopped at the flip prompt or with Abort at a multi-page question, not a failure. The human-readable labels the table view prints ("Complete", "Failed", "Saved to folder", "Cancelled", "Waiting for more pages") never appear in --json.

created_at is always a UTC ISO-8601 timestamp carrying the +00:00 offset, whatever timezone the server is in, so a script can parse it without knowing where the appliance lives. The table saneless jobs prints without --json is the other way round: it renders the same instant in the server's local timezone with the zone named, for example 2026-03-22 09:30 CDT. Only the table follows the server's timezone; the JSON never does.

outcome is "SUCCESS", "FALLBACK" or null, and warning carries a note about something odd that did not fail the scan, or null. A job whose state is FALLBACK was scanned and saved, but paperless-ngx did not accept it over the API, so the PDF went to the consume directory and its title, tags and correspondent were not applied. A job whose state is DONE and whose warning is set was uploaded with a warning -- a sheet the scanner skipped, or a manual duplex scan uploaded as two documents -- and the table view labels it "Uploaded with a warning" rather than "Complete".

error holds the full stored text of what stopped a job -- a failure, a refused submit or a cancellation -- or null. It includes any file path on the server and the paperless-ngx URL. The web page shows the same failure only as a sentence with no paths in it, which points here: saneless jobs --json is where to find where a failed scan's PDF was kept.

pages_scanned, pages_removed and pages_uploaded are the page counts the web page shows under a finished scan: how many pages the scanner produced, how many were removed as blank, and how many went to paperless-ngx. pages_removed_positions lists the pages that were removed as blank, by their scanned page number in document order, so [2, 4] with pages_scanned 4 means the second and fourth scanned pages. For a duplex scan that is the page number in the interleaved document. Removed pages are not kept, so these numbers are how you find the sheets to rescan if a real page was taken for a blank one. The list is information, not a warning: a DONE job that removed blank pages keeps "warning": null.

All four are null when the job never recorded them, as for a failed, cancelled or refused job. A measured zero is 0 and an empty list is [], never null.

Exit codes

A script that runs saneless scan needs to know which exit codes to treat differently. The basic scan example branches on these codes and sends any other code to a fallback that prints it. CLI Commands defines every code, including 129 and 143, which the example leaves to its fallback, and 141, which saneless scan never returns.

Exit code What a script should do
0 Nothing: the document was uploaded
1 Check that the scanner is switched on, connected and loaded, then scan again
2 Fix the configuration, the profile name or the job database first: scanning again unchanged fails the same way
3 Fix paperless-ngx or the connection to it (stderr says which), then scan again
4 Check that the output directory can be written, because the scanned pages could not be written as a PDF. A full disk is exit 10
5 Report a bug and attach the log file
6 Do not rescan: the document was delivered to the consume folder. Fix the connection to paperless-ngx so the next scan keeps its title, tags and correspondent
7 Do not rescan the whole stack: the document was delivered. Check it in paperless-ngx; the warning is on stderr
8 Check the pages kept in failed/ before scanning again: the scanner worked, but every page looked blank. If they are not blank, lower empty_page_coverage_threshold
9 Never rescan on 9: check paperless-ngx's document list first, and import the copy the error line names only if the document is not there
10 Free disk space on the server, then scan again
130 Nothing: someone cancelled the scan

129 and 143 mean the command was stopped from outside, by a hangup or by SIGTERM. After either, look for a kept file only where the Interrupted: line on stderr names one: a line that names no path means nothing was kept. A signal that arrives once a scan's outcome is settled does not change it, so the script sees that outcome's own code instead.

141 is 128 plus SIGPIPE, the shell's own code for a broken pipe. A pipeline reports the last command's status unless set -o pipefail is set, so saneless jobs --json | head gives head's status, and the 141 shows only under pipefail.

A failure prints a line to stderr saying what failed, then a Try: line with the next step (a configuration error prints a header naming the file and one line per problem, then the Try: line). A script that parses the failure should read the first line: the Try: line is advice for a person, and its wording may change. A cancel (130), an interruption (129, 143) and an unexpected error (5) print one line and no Try: line, and a broken pipe (141) prints nothing. Troubleshoot a Failed Scan says what to check for each code.

saneless doctor's exit code

saneless doctor reports six health checks and collapses them to one code:

  • 0 — every check came out OK, or came out as a warning. Most warnings are true statements about a deployment that still scans and files: no fallback folder is configured, or the generated profiles live only in memory. They are deliberately not failures, because a gate that goes red for tidiness is a gate people learn to ignore. One warning is different: a scanner host that does not answer, even when it is the only one configured. The check stops there without asking SANE for scanners, so it cannot say that none would be found.
  • 2 — at least one check failed: scanner support is not installed or will not start, no usable scanner was found, the paperless-ngx API token is unset or rejected, paperless-ngx could not be used, no scan profiles are configured, or a folder saneless needs is not writable. A folder problem is a row like any other: doctor still prints all six rows, where the other commands refuse to start. A configuration file that cannot be loaded at all is also 2.
  • 5, 129, 130 and 143 behave as they do for every other command.

So exit 0 means that nothing doctor could check is broken. It does not prove that a scan will work, because the scanner may simply be switched off. doctor prints a human-readable table and has no --json mode, so a script that runs it should read the exit code rather than parse the output. Do not wire it to a container HEALTHCHECK: it probes the scanner and talks to paperless-ngx, so it would mark the container unhealthy during a routine paperless-ngx restart. Use the web server's /health endpoint for that.

Manual duplex profiles cannot be scripted

A profile with duplex = "manual" needs a person to flip the stack between the two passes, and saneless scan asks for that confirmation at the terminal. When stdin is not a terminal -- cron, a pipe, redirected input, most CI runners -- saneless scan refuses the profile and exits with code 2 before the scanner feeds a single page, so no stack is half-scanned:

Profile 'manual-duplex' is manual duplex, which needs an interactive terminal: saneless must prompt you to flip the stack between the two passes. Run it from a terminal, or scan from the web UI.

Use a simplex or hardware duplex profile for automation. From a terminal, answering no, Ctrl-D or Ctrl-C at the flip prompt cancels the scan and exits with code 130. A flip wait that is not confirmed within operator_wait_timeout_seconds, or a read error at the prompt (an I/O error, or input that cannot be decoded), fails the scan with code 1. See Set Up ADF Duplex Scanning.

--multi-page cannot be scripted either

saneless scan --multi-page asks after every scan whether there is another page, so it is interactive only. When stdin is not a terminal it is refused with exit code 2 before the scanner is opened:

--multi-page needs an interactive terminal: saneless asks after each scan whether there is another page. Run it from a terminal, or scan from the web UI.

There is no scripted form with a fixed number of pages. See Scan a Multi-Page Document.

Scripting examples

Basic scan with error handling

--title may be omitted, in which case the profile's title is used; with neither, the title is the scan's start time in the server's local timezone with the zone named, for example Scan 2026-03-22 09:30 CDT. So a cron entry can rely on a profile title instead of building one.

#!/bin/bash
saneless scan --profile default --title "Automated Scan"
exit_code=$?
case $exit_code in
  0) echo "Scan uploaded successfully" ;;
  1) echo "Scan failed -- check the scanner is switched on and connected" ;;
  2) echo "Configuration error -- check the profile name and the config" ;;
  3) echo "Upload failed -- check paperless-ngx connection" ;;
  4) echo "PDF assembly failed -- check the output directory" ;;
  5) echo "Unexpected error -- see the log file and report a bug" ;;
  6) echo "Saved to the consume folder without its title, tags or correspondent -- do not rescan; fix the paperless-ngx connection" ;;
  7) echo "Uploaded with a warning -- check the document in paperless-ngx (see stderr)" ;;
  8) echo "Every page looked blank -- check the pages kept in failed/ before scanning again" ;;
  9) echo "May already be in paperless-ngx -- do not rescan; check its document list first" ;;
  10) echo "Out of disk space -- free space on the server, then scan again" ;;
  130) echo "Cancelled" ;;
  *) echo "saneless exited with code $exit_code -- see stderr" ;;
esac
exit $exit_code

Only exit 0 is a clean success. Exits 6 and 7 both mean the document was delivered, and 9 means it may have been, so the script must not scan the stack again on any of them. Neither 6 nor 7 is a success to ignore: 6 means every scan is going to the consume folder until the connection to paperless-ngx is fixed, and 7 means a document needs checking. saneless scan is not recorded in the job history, so its exit code and stderr are the only report a script gets.

Checking readiness before a scan

saneless doctor's exit code is not a readiness check: a scanner that is switched off is a warning, and doctor exits 0. Gate a scan on the scanner itself instead, by asking which devices SANE can see:

#!/bin/bash
if ! saneless devices --json | jq -e 'length > 0' >/dev/null; then
  echo "No scanner found -- run saneless doctor to see why"
  exit 1
fi
saneless scan --profile adf --title "Batch $(date +%Y-%m-%d)"

jq -e exits non-zero when the list is empty, and also when saneless devices failed and printed nothing. When it does, run saneless doctor by hand: its rows say what is wrong.

Scan only if scanner is available

#!/bin/bash
if saneless devices --json | jq -e 'length > 0' >/dev/null; then
  saneless scan --profile adf --title "Batch $(date +%Y-%m-%d)"
else
  echo "No scanner found, skipping"
fi

Without the check, a scan that finds no scanner exits 1, the same code as any other scanner failure, and so does saneless auto-profiles. The first stderr line then starts Scan error: No scanner found, so a script that must tell the two apart can match on it:

#!/bin/bash
saneless scan --profile adf --title "Batch $(date +%Y-%m-%d)" 2>scan.err
exit_code=$?
if [ "$exit_code" -eq 1 ] && grep -q '^Scan error: No scanner found' scan.err; then
  echo "No scanner found, skipping"
  exit 0
fi
exit $exit_code

Scheduled scanning with cron

Add a cron job to scan at a specific time (useful for shared office scanners with a known document tray):

# Scan every weekday at 9:00 AM
0 9 * * 1-5 $HOME/.local/bin/saneless --config $HOME/.config/saneless/saneless.toml scan --profile adf --title "Morning batch $(date +\%Y-\%m-\%d)" >> $HOME/saneless-cron.log 2>&1

Cron runs a job with almost none of your login environment. Its PATH is /usr/bin:/bin, so a saneless that pipx installed in ~/.local/bin is not found by name: give the full path. The job also starts in your home directory, so a ./saneless.toml in the directory you usually scan from is not the file it loads, and an XDG_CONFIG_HOME your shell sets is not set. --config names the file, and it goes before the subcommand because it is an option of saneless itself, not of scan. % is special in a crontab line, so the date format escapes it as \%.

Configuration in scripts and containers

A set SANELESS_* environment variable overrides the same setting in saneless.toml; see Where saneless reads settings. In a script or a container, keep the paperless-ngx URL and API token in saneless.toml rather than in the environment, so there is one place to change them and nothing overrides the file without saying so. The variable a script or container usually sets is the scanner host, which differs from one machine to the next:

export SANELESS_SCANNER__HOST="192.168.1.50"

saneless scan --profile default --title "Scripted scan"

Regenerating profiles

If your scanner's capabilities change (e.g., after a firmware update or switching scanners), regenerate auto-profiles:

saneless auto-profiles --force

This refreshes the generated keys of profiles marked auto_generated = true from the scanner's current capabilities, and keeps their other keys, such as default_tags and title. Profiles you wrote yourself are never changed, even when one has the same name as a generated profile; it is reported as skipped. See Auto-generated profiles.