QR Send CLI guide for power users

Direct answer

./qrsend [options] <file|-> streams a file as QR codes in your terminal, and - reads standard input, so tar cz ./logs | ./qrsend - works. The flags split into throughput, scan reliability, operation and evidence. Frame rate defaults to 15 fps, and the same code also ships as a WASI module.

Key takeaways

The command shape

There is one positional argument, a file path or -. Everything else is a flag:

# a normal file send, labelled for the receiver
./qrsend --fps 12 --label firmware.bin ./firmware.bin

# compress first, then pipe the archive through stdin
tar cz ./logs | ./qrsend --label logs.tgz -

# the WASI build, file piped on stdin to avoid directory preopens
wasmer run qrsend.wasm -- --label logs.tgz - < ./logs.tgz

Streaming from a pipe is the pattern for air-gapped file transfer work: tar cz or zstd -19 runs on the machine that holds the data, and qrsend never needs read access to the tree. The caveat is that - is refused when path policy is active, because a stdin source cannot be checked against a whitelist. When you use stdin, --label is the only thing that names the transfer, so set it.

Flag groups

GroupFlagsWhat changes
Throughput--fps, --cols, --rows, --packets-per-qr, --qrs-per-framehow often the terminal redraws and how much data each drawn symbol carries
Scan reliability--ecc, --invert, --fg, --bgerror correction level and the contrast the receiving camera actually sees
Operation--loops, --once, --interactive, --labelhow long the stream runs, what it is called, whether a human confirms it
Evidence--log-audit, --dump-batches-json, --manifest-onlyaudit trail, packet inspection, pre-flight manifest

Throughput: frame rate and grid size

--fps sets terminal updates per second, default 15. --cols and --rows override the auto-detected terminal size, and that is the real lever: a fixed 80x24 page fits few QR symbols, while 160x50 fits roughly four times the modules, which is where the documented jump from a few kB/s to about 15 kB/s comes from. --qrs-per-frame forces how many QR codes are rendered in each terminal frame, and --packets-per-qr targets the logical packet count inside one QR symbol. Both are normally left on auto: raising them packs more bytes per frame but shrinks every module, which is the wrong trade when the camera is far away.

The terminal sender is a deliberately low-bandwidth channel. For live camera or microphone, the browser path is the right tool — see live media over QR, which carries MediaRecorder fragments at roughly 200-300 kbps.

Scan reliability: error correction and colour

--ecc low|medium|quartile|high selects the QR error correction level. Higher levels survive more damage and cost payload on every symbol. --invert swaps foreground and background; --fg and --bg set them explicitly, accepting black, white or #RRGGBB. These are the flags you reach for when the receiver sees the code but cannot lock on: invert first, then raise --ecc, then slow --fps.

Operation: loops, confirmation, labels

--loops N repeats the whole stream N times, and --once is shorthand for a single pass. Leaving both off repeats indefinitely, which is the right default for a display that people walk up to, such as a kiosk screen. --interactive prints the pre-flight manifest and waits for operator confirmation before the first frame is rendered. --label sets the transmitted filename, and --stream-id sets the transfer id, 15 bits effective, so a receiver can group or select a known transfer.

Evidence: manifest, batches, audit

--manifest-only prints the manifest and exits before streaming, so it can gate a change request without transmitting anything. --dump-batches-json emits the logical packets behind each batch as JSON instead of rendering, which is how you measure what a given --packets-per-qr actually produces. --log-audit <path> appends JSON audit events; --verbose mirrors them to stderr, keeping stdout reserved for pixels.

Worked tuning: a dim projector and a bright monitor

Same file, two rooms, opposite settings. A dim projector wants fewer, larger, more redundant symbols; a bright monitor can afford density.

SettingDim projector, dark roomBright monitor, office light
--fps818
--cols / --rows120 / 34160 / 48
--eccquartilelow
Colour--invert --fg white --bg '#000000'--fg '#111111' --bg white
--packets-per-qrdefaultdefault
Effectslower stream, fewer failed framesfastest the terminal allows
# A) dim projector, long throw: fewer, bigger symbols with more redundancy
./qrsend --fps 8 --cols 120 --rows 34 --ecc quartile \
         --invert --fg white --bg '#000000' --label slides.pdf ./slides.pdf

# B) bright office monitor: maximum density, minimum redundancy, one pass
./qrsend --fps 18 --cols 160 --rows 48 --ecc low \
         --fg '#111111' --bg white --once --label bundle.tar.zst ./bundle.tar.zst

Tune in this order. If the receiver misses symbols, drop --fps before you touch the grid, because a missed redraw costs less than a smaller symbol. If the receiver never sees a symbol at all, it is a contrast problem: --invert or a --fg/--bg pair aimed at the actual screen. Only then spend payload on --ecc. And compress before sending; the CLI does not compress for you.

The audit log

Audit events are JSON records: TRANSFER_REQUESTED, TRANSFER_COMMENCED, TRANSFER_COMPLETED and TRANSFER_INTERRUPTED. --log-audit /var/log/qrsend-audit.jsonl appends them to a file. --verbose sends the same records to stderr, so piping stdout keeps the QR stream clean.

Syslog and journald delivery are enabled through policy rather than flags, in /etc/qrsend/send.cfg:

# /etc/qrsend/send.cfg
interactive=true
audit_log=/var/log/qrsend-audit.jsonl
audit_syslog=true
audit_journald=false
whitelist_path=/srv/approved
blacklist_path=/srv/approved/secrets

audit_syslog=true writes local syslog events through the OS API, so a daemon or collector must be running if you want them persisted, and forwarding to a SIEM stays in your existing syslog pipeline. audit_journald=true is Linux-only and writes to /run/systemd/journal/socket. The same file carries the whitelist and blacklist path policy, and the optional /etc/qrsend/whitelist.d/ and /etc/qrsend/blacklist.d/ directories extend it one absolute path prefix per line. Two consequences worth remembering: with any whitelist configured, stdin sources are rejected, and with --interactive, declining the prompt writes no transfer event at all.

FAQ

Does qrsend work in a shell pipeline?

Yes. Use - as the source and set --label: tar cz ./logs | ./qrsend --label logs.tgz -. The receiver sees the label as the filename, since a pipe has no path.

Why did qrsend reject my - input?

Path policy is active. When a whitelist is configured, a stdin source cannot be checked against it, so qrsend refuses rather than sending an unaudited path. Send the file by path or exempt it in policy.

What is the difference between --once and --loops?

--once is exactly --loops 1: one pass through the data and exit. --loops 5 repeats five times, and omitting both repeats indefinitely so a late scanner can still recover the file.

Where do audit events go?

By default nowhere. --log-audit <path> writes JSONL to a file, --verbose writes JSON to stderr, and audit_syslog / audit_journald in send.cfg add the OS log. They can be combined.

Can I run it without installing a native binary?

Yes. The same sender ships as a WASI module, runnable with wasmer run qrsend.wasm -- --label file.bin - < file.bin, with a thin shell launcher that fills in --cols and --rows from the current terminal. Piping on stdin avoids dealing with WASI directory preopens.

Related reading

References