Cron-based conversion runs on a schedule; event-driven conversion runs the moment a file lands. For workflows where latency matters — a screen capture that should be available as WebP within seconds, a user upload that should be archived as PDF before the next request — inotifywait (Linux) and launchd WatchPaths (macOS) are the right tools.

TL;DR

Linux: inotifywait -m -e close_write /watch/dir emits one line per completed file write. Read those lines in a while loop, convert via API, done. macOS: a launchd plist with WatchPaths wakes a script whenever the watched directory changes.

The use case

A design tool exports PNG screenshots to a shared directory. You want each new PNG converted to WebP within a few seconds for CDN upload. Or a scanner drops TIFFs into a network share and you need them as PDFs for the document management system before the next workflow step runs.

Polling with a short-interval cron (every minute) has two problems: it misses files added within the poll window and wastes CPU on empty checks. inotifywait is the correct answer on Linux: kernel-level filesystem events, zero polling overhead, no missed events.

Key events to watch:

  • close_write — fired when a process closes a file it opened for writing. This is the right event: create fires before data is written, close_write fires when the write is complete.
  • moved_to — fired when a file is moved into the watched directory. Many apps write to a temp file and rename into the target dir — this event catches those.

Linux: inotifywait script

#!/usr/bin/env bash
set -euo pipefail

# ---- config ----------------------------------------------------------------
API_KEY="${CTF_API_KEY:?CTF_API_KEY not set}"
WATCH_DIR="${CTF_WATCH_DIR:-/data/incoming}"
OUTPUT_DIR="${CTF_OUTPUT_DIR:-/data/converted}"
TARGET_FORMAT="${CTF_TARGET_FORMAT:-pdf}"
API_URL="https://changethisfile.com/v1/convert"
# ---------------------------------------------------------------------------

log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*"; }
mkdir -p "$OUTPUT_DIR"

# Requires: apt install inotify-tools
log "Watching $WATCH_DIR for new files (target format: $TARGET_FORMAT)..."

inotifywait -m -q \
  --format '%f' \
  -e close_write \
  -e moved_to \
  "$WATCH_DIR" | \
while IFS= read -r filename; do
  filepath="$WATCH_DIR/$filename"

  # Skip hidden files, temp files, already-converted output
  [[ "$filename" == .* ]] && continue
  [[ "$filename" == *.tmp ]] && continue
  [[ "$filename" == *.$TARGET_FORMAT ]] && continue

  # Wait briefly for the file to be fully flushed (some tools re-open)
  sleep 0.5

  stem="${filename%.*}"
  outfile="$OUTPUT_DIR/${stem}.${TARGET_FORMAT}"

  log "Converting: $filename -> ${stem}.${TARGET_FORMAT}"

  http_status=$(curl -sf \
    --max-time 120 \
    -w "%{http_code}" \
    -H "Authorization: Bearer $API_KEY" \
    -F "file=@$filepath" \
    -F "target=$TARGET_FORMAT" \
    -o "$outfile" \
    "$API_URL" 2>/dev/null
  ) || { log "ERROR curl failed for $filename"; continue; }

  if [[ "$http_status" == "200" ]]; then
    log "OK $filename ($(stat -c%s "$outfile") bytes)"
  else
    log "ERROR HTTP $http_status for $filename"
    rm -f "$outfile"
  fi
done

Install inotify-tools: apt install inotify-tools (Debian/Ubuntu) or dnf install inotify-tools (RHEL/Fedora).

macOS: launchd WatchPaths

macOS doesn't have inotifywait. launchd's WatchPaths key triggers a job whenever a file in the specified path changes.

Create the plist at ~/Library/LaunchAgents/com.yourname.ctf-watcher.plist:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>com.yourname.ctf-watcher</string>
  <key>WatchPaths</key>
  <array>
    <string>/Users/you/Desktop/incoming</string>
  </array>
  <key>ProgramArguments</key>
  <array>
    <string>/bin/bash</string>
    <string>/usr/local/bin/ctf-watch.sh</string>
  </array>
  <key>EnvironmentVariables</key>
  <dict>
    <key>CTF_API_KEY</key>
    <string>ctf_sk_your_key_here</string>
    <key>CTF_WATCH_DIR</key>
    <string>/Users/you/Desktop/incoming</string>
    <key>CTF_OUTPUT_DIR</key>
    <string>/Users/you/Desktop/converted</string>
    <key>CTF_TARGET_FORMAT</key>
    <string>pdf</string>
  </dict>
  <key>StandardOutPath</key>
  <string>/tmp/ctf-watcher.log</string>
  <key>StandardErrorPath</key>
  <string>/tmp/ctf-watcher-error.log</string>
</dict>
</plist>

The conversion script (/usr/local/bin/ctf-watch.sh) should handle the entire directory on each trigger (launchd fires on any change, not per-file):

#!/usr/bin/env bash
set -euo pipefail
API_KEY="${CTF_API_KEY:?}"
WATCH_DIR="${CTF_WATCH_DIR:-$HOME/Desktop/incoming}"
OUTPUT_DIR="${CTF_OUTPUT_DIR:-$HOME/Desktop/converted}"
TARGET="${CTF_TARGET_FORMAT:-pdf}"
mkdir -p "$OUTPUT_DIR"

for f in "$WATCH_DIR"/*; do
  [[ -f "$f" ]] || continue
  filename=$(basename "$f")
  stem="${filename%.*}"
  out="$OUTPUT_DIR/${stem}.$TARGET"
  [[ -e "$out" ]] && continue  # already converted
  curl -sf \
    --max-time 120 \
    -H "Authorization: Bearer $API_KEY" \
    -F "file=@$f" \
    -F "target=$TARGET" \
    -o "$out" \
    https://changethisfile.com/v1/convert
  echo "Converted: $filename"
done

Load and start the agent: launchctl load ~/Library/LaunchAgents/com.yourname.ctf-watcher.plist

Running as a persistent service (Linux)

Run the inotifywait script as a systemd service so it survives reboots:

# /etc/systemd/system/ctf-watcher.service
[Unit]
Description=ChangeThisFile folder watcher
After=network.target

[Service]
Type=simple
User=www-data
EnvironmentFile=/etc/ctf.env
ExecStart=/opt/scripts/ctf-watch.sh
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable --now ctf-watcher
journalctl -u ctf-watcher -f  # Follow logs

The Restart=on-failure directive means if the script exits unexpectedly (network error, disk full), systemd restarts it after 5 seconds.

Production tips

  • Watch close_write, not create. The create event fires the instant a file appears, before data is written. close_write fires when the writer closes the file descriptor — the only event that guarantees the file is complete.
  • sleep 0.5 after the event. Some tools (rsync, screen capture software) re-open files briefly after the initial write. A half-second delay avoids converting a still-open file.
  • Also watch moved_to. Many tools write to a temp file in the same directory and then rename into place. The rename triggers moved_to, not close_write.
  • inotifywait -r for subdirectories. Add -r to watch a directory tree recursively. This adds a --exclude option to filter by path pattern: --exclude '.*\.${TARGET_FORMAT}$' skips already-converted files.
  • launchd re-fires on any directory modification. Including when you write the output file. The conversion script must check for existing output files (the [[ -e $out ]] check) or you'll get an infinite loop.

inotifywait on Linux and launchd WatchPaths on macOS both eliminate polling overhead entirely. Files are converted within a second or two of landing in the watched directory. Get a free API key — 1,000 conversions/month, no card required.