How to Automate Screenshot Naming on macOS

How to Automate Screenshot Naming on macOS


Screenshots on macOS are great and one of those features I use daily in my workflows. One issue I run into from time to time can be the sheer clutter that can be generated during intense coding sessions, so the other day I spent some time writing a simple helper to help classify and organize screenshots using AI. The plan:

  1. Watch desktop for new files matching "Screenshot(.*).png"
  2. Prompt Claude to generate a descriptive name for the file
  3. Re-name the file locally and handle edge cases

This document goes over two different approaches to solving this problem using native solutions on MacOS:

  • Option A: Bash script w/ Automator
  • Option B: Swift program w/ launchd

I would recommend using Option B as I found it more reliable and easier to setup as a developer, but both are listed below.

Source Code: https://github.com/asleepace/ai-rename-screenshot

Prerequisites

To get started you just need to be on MacOS and have an api key for Anthropic. This article uses Claude to analyze and annotate images, but you could swap out this logic with whatever provider you prefer using. The first step for both options is to store this api key in your Keychian Access:

# run the following in your terminal to store your api key in your keychain:
security add-generic-password -a "$USER" -s "CLAUDE_API_KEY" -w "sk-ant-..."

Verify this worked by opening the Keychian Access app by pressing Command+Spacebar and entering "Keychian Access". It may ask you if you want to go to Passwords or Keychian Access when first opened, if so choose Keychain Access.

Spotlight search for Keychain Access

Then you can search for the newly created key under Login Items by entering "CLAUDE_API_KEY" in the search bar:

Stored api key in Keychain Access

The reason we are storing in the keychain is to prevent hardcoding our api keys in the actual code and is overall just a best practice. You can however skip this step and just include manually in the code if that’s your preference.

Also, a few other specifics about my particular environment just for reference:

  • Device: Macbook Pro Apple M1 Max
  • MacOS: 26.3 (25D125)
  • Swift: 6.2.4

Option A - Bash script w/ Automator

This option is a bit easier to setup and get working but requires using Apple’s Automator application to watch for newly created files on the desktop. This application is installed by default on MacOS and can be found by searching for "Automator" in Spotlight search.

What is Automator?

Automator is a macOS app that lets you build automated workflows without code by chaining together pre-built actions for things like “Get Finder Items”, “Run Shell Script”, “Rename Files”, etc.

It supports a few workflow types:

  • Folder Action — triggers when files are added to a folder (what we were using)
  • Application — runs when you open it
  • Quick Action — appears in right-click menus
  • Calendar Alarm — runs on a schedule

It’s essentially Apple’s no-code automation tool, similar to Shortcuts but older and more desktop-focused.

https://support.apple.com/guide/automator/welcome/mac

Setting up the Automation

  1. Open Automator → New Document → Folder Action
  2. Set “Folder Action receives files and folders added to:” → Desktop
  3. Add action: Run Shell Script
  4. Set “Pass input:” → as arguments
Create new automator folder action

To make sure you have everything setup correctly and working, start by pasting this Bash script and then saving with Command+S and naming something like “Ai Rename Screenshots”.

Example automation setup

This script is just used to verify the automation is setup and receiving files:

#!/bin/bash

# Iterate over newly created files:
for f in "$@"; do
  filename=$(basename "$f")
  filepath="$f"
  timestamp=$(date '+%Y-%m-%d %H:%M:%S')

  # Log new file to desktop log
  echo "[$timestamp] New file: $filename" >> ~/Desktop/automator.log

done

Once ready take a screenshot with Command+Shift+4 and make sure you see the log file generated on your desktop. The contents should looks something like:

[2026-04-16 06:18:32] New file: Screenshot 2026-04-16 at 9.08.32 AM.png
[2026-04-16 06:18:38] New file: automator.log

If the log doesn’t appear like this make sure that your screenshots are being saved to the desktop and that your automation has "Pass input: as arguments" set.

Once everything looks good and is ready to go you can replace the test script above with this full script:

#!/bin/bash

# Load CLAUDE_API_KEY from keychain
CLAUDE_API_KEY=$(security find-generic-password -s "CLAUDE_API_KEY" -w 2>/dev/null)

# Output log destination:
LOG="$HOME/Library/Logs/automator-screenshots.log"

# Verify api key is set
if [[ -z "$CLAUDE_API_KEY" ]]; then
  echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: No CLAUDE_API_KEY API key found in keychain" >> "$LOG"
  exit 1
fi

# Iterate over newly added files
for f in "$@"; do
  filename=$(basename "$f")

  # Filter: only process Screenshot*.png files
  if [[ ! "$filename" == Screenshot*.png ]]; then
    continue
  fi

  echo "[$(date '+%Y-%m-%d %H:%M:%S')] Processing: $filename" >> "$LOG"

  # 1. Compress using sips (built-in macOS)
  compressed="/tmp/compressed_$filename"
  sips --resampleHeightWidthMax 1280 "$f" --out "$compressed" &>/dev/null

  # 2. Base64 encode
  b64=$(base64 -i "$compressed")

  # 3. Send to Claude for a descriptive name
  response=$(curl -s https://api.anthropic.com/v1/messages \
    -H "x-api-key: $CLAUDE_API_KEY" \
    -H "anthropic-version: 2023-06-01" \
    -H "content-type: application/json" \
    -d "{
      \"model\": \"claude-haiku-4-5\",
      \"max_tokens\": 64,
      \"messages\": [{
        \"role\": \"user\",
        \"content\": [
          {
            \"type\": \"image\",
            \"source\": {
              \"type\": \"base64\",
              \"media_type\": \"image/png\",
              \"data\": \"$b64\"
            }
          },
          {
            \"type\": \"text\",
            \"text\": \"Generate a short, descriptive kebab-case filename (no extension, max 5 words) for this screenshot. Reply with ONLY the filename, nothing else.\"
          }
        ]
      }]
    }")

  # 4. Parse name and rename
  new_name=$(echo "$response" | grep -o '"text":"[^"]*"' | head -1 | sed 's/"text":"//;s/"//')

  if [[ -n "$new_name" ]]; then
    dir=$(dirname "$f")
    new_path="$dir/${new_name}.png"
    mv "$f" "$new_path"
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] Renamed to: ${new_name}.png" >> "$LOG"
  else
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] Failed to get name for: $filename" >> "$LOG"
  fi

  rm -f "$compressed"
done

This Bash script is called by Automator when new files are added to the desktop and does the following:

  • Uses sips (zero dependencies, built into macOS) for compression
  • Downsizes to max 1280px to reduce API payload
  • Claude is prompted to return only a kebab-case name, e.g. dark-mode-settings-panel.png
  • Renames the original file in-place on the Desktop

Make sure that you have added the "CLAUDE_API_KEY" keychain item we set in prerequisites! You could also remove this line CLAUDE_API_KEY=$(security find-generic-password -s "CLAUDE_API_KEY" -w 2>/dev/null) if you have this already set in your ~/.zshrc or you can replace with a hardcoded value as well (but not recommended).

To view logs from this script you can run the following in your terminal:

tail -f ~/Library/Logs/automator-screenshots.log

NOTE: If you run into any issues see the Troubleshooting section below.

Now take a screenshot and watch it be renamed automatically!


Option B - Swift program w/ launchd

While the above approach with Automator and Bash works, it felt a bit cumbersome and hard to debug. Also handling more complex edge cases with Bash wasn’t ideal for me, so I reached for something a bit more programmatic.

The solution was a standalone Swift file which could be compiled to a binary and registered with launchd to run automatically in the background when the computer is started.

What is launchd?

launchd is macOS’s system and service manager and is the first process that runs when macOS boots (PID 1). It is responsible for starting and managing all other processes.

It replaces older Unix tools like cron and init. You interact with it via launchctl and define services using .plist files stored in a few locations:

  • ~/Library/LaunchAgents/ — runs as your user on login (what we’re using)
  • /Library/LaunchAgents/ — runs as your user for all users
  • /Library/LaunchDaemons/ — runs as root at boot, no user session needed

It handles things like auto-restart (KeepAlive), logging, run-at-load, and watching paths.

https://support.apple.com/guide/terminal/script-management-with-launchd

Creating a Swift program

NOTE: If you also setup the automator action in the previous section, you will need to disable this first!

To get started create a new folder somewhere called swift-scripts which will be used to contain our Swift code, output binary and .plist file.

# create a new folder for the script
mkdir ~/swift-scripts
cd ~/swift-scripts

# create an empty Swift file
touch ai-rename-screenshot.swift

# open the file in your editor
open ai-rename-screenshot.swift

Then go ahead and paste the following code into ai-rename-screenshot.swift

//
//  @file ai-rename-screenshot.swift
//  @description automatically rename screenshots with ai.
//  @created on April 15th, 2026
//  @author asleepace
//    
import AppKit
import CoreServices
import Foundation

// MARK: - Configuration (editable)

let anthropicModel: String = "claude-haiku-4-5"
let folderName: String = "Desktop"
let maxProcessed: Int = 50
let delayInSeconds: UInt64 = 1
let systemPrompt: String = """
Create descriptive title for screenshot in 2-6 words, lowercase, hyphen-separated. Return only the filename with no extension. Example: safari-github-pull-request
"""

func isScreenshot(_ filename: String) -> Bool {
    filename.hasPrefix("Screenshot") && filename.hasSuffix(".png")
}

// MARK: - Internal State

var processed = Set<String>()

func hasNotProcessed(_ path: String) -> Bool {
    if processed.contains(path) { return false }
    processed.insert(path)
    if processed.count > maxProcessed {
        processed.removeFirst()
    }
    return true
}

// MARK: - Keychain

func getSecret(_ name: String) -> String? {
    let task = Process()
    task.executableURL = URL(fileURLWithPath: "/usr/bin/security")
    task.arguments = ["find-generic-password", "-a", NSUserName(), "-s", "\(name)", "-w"]
    let pipe = Pipe()
    task.standardOutput = pipe
    task.standardError = Pipe()
    try? task.run()
    task.waitUntilExit()
    let data = pipe.fileHandleForReading.readDataToEndOfFile()
    return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
}

// MARK: - Image Compression

func compressImage(_ path: String, maxDimension: CGFloat = 1024, quality: CGFloat = 0.7) -> Data? {
    guard let image = NSImage(contentsOfFile: path) else { return nil }

    let originalSize = image.size
    let scale = min(maxDimension / originalSize.width, maxDimension / originalSize.height, 1.0)
    let newSize = NSSize(width: originalSize.width * scale, height: originalSize.height * scale)

    let resized = NSImage(size: newSize)
    resized.lockFocus()
    image.draw(in: NSRect(origin: .zero, size: newSize))
    resized.unlockFocus()

    guard let tiff = resized.tiffRepresentation,
          let bitmap = NSBitmapImageRep(data: tiff),
          let jpeg = bitmap.representation(using: .jpeg, properties: [.compressionFactor: quality])
    else { return nil }

    return jpeg
}

// MARK: - Claude API

func renameScreenshot(_ path: String) async {
    // add 0.5s delay before compressing image to let system store file
    try? await Task.sleep(nanoseconds: delayInSeconds * 1_000_000_000)

    guard let apiKey = getSecret("CLAUDE_API_KEY"), !apiKey.isEmpty else {
        print("❌ CLAUDE_API_KEY not found in keychain")
        return
    }

    guard let imageData = compressImage(path) else {
        print("❌ Could not compress image: \(path)")
        return
    }

    print("📦 Compressed to \(imageData.count / 1024)KB")

    let base64 = imageData.base64EncodedString()
    let dir = URL(fileURLWithPath: path).deletingLastPathComponent().path

    let payload: [String: Any] = [
        "model": anthropicModel,
        "max_tokens": 64,
        "messages": [[
            "role": "user",
            "content": [
                [
                    "type": "image",
                    "source": [
                        "type": "base64",
                        "media_type": "image/jpeg",
                        "data": base64
                    ]
                ],
                [
                    "type": "text",
                    "text": "\(systemPrompt)"
                ]
            ]
        ]]
    ]

    guard let body = try? JSONSerialization.data(withJSONObject: payload) else {
        print("❌ Failed to serialize payload")
        return
    }

    var request = URLRequest(url: URL(string: "https://api.anthropic.com/v1/messages")!)
    request.httpMethod = "POST"
    request.httpBody = body
    request.setValue(apiKey, forHTTPHeaderField: "x-api-key")
    request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version")
    request.setValue("application/json", forHTTPHeaderField: "content-type")

    do {
        let (data, _) = try await URLSession.shared.data(for: request)
        guard
            let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
            let content = (json["content"] as? [[String: Any]])?.first,
            let text = content["text"] as? String
        else {
            print("❌ Failed to parse response")
            return
        }

        let newName = text
            .lowercased()
            .components(separatedBy: CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-")).inverted)
            .joined(separator: "-")
            .trimmingCharacters(in: CharacterSet(charactersIn: "-"))

        guard !newName.isEmpty else {
            print("❌ Empty filename returned")
            return
        }

        var newPath = "\(dir)/\(newName).png"

        // prevent naming collisions:
        if FileManager.default.fileExists(atPath: newPath) {
            newPath = "\(dir)/\(newName)-\(Int(Date().timeIntervalSince1970)).png"
        }
        
        // save output file:
        try FileManager.default.moveItem(atPath: path, toPath: newPath)
        print("✓ Renamed to: \(newName).png")
    } catch {
        print("❌ Error: \(error)")
    }
}

// MARK: - FSEvents

let desktopURL = FileManager.default.homeDirectoryForCurrentUser
    .appendingPathComponent(folderName)

let queue = DispatchQueue(label: "com.asleepace.ai-screenshot-analyzer")

// NOTE: This is the callback that is triggered each time the Desktop is updated.
let callback: FSEventStreamCallback = { _, _, numEvents, eventPaths, eventFlags, _ in
    let paths = Unmanaged<CFArray>.fromOpaque(eventPaths).takeUnretainedValue() as! [String]
    let flags = UnsafeBufferPointer(start: eventFlags, count: numEvents)

    for (path, flag) in zip(paths, flags) {
        // extract filename from path
        let filename = URL(fileURLWithPath: path).lastPathComponent

        print("Event: \(filename) flags: \(flag)")  // see what's coming in

        // only trigger for newly created files
        guard flag & UInt32(kFSEventStreamEventFlagItemCreated) != 0 else { continue }

        // Note sometimes the file is saved as a tmp file, so handle those differently:
        if filename.hasPrefix(".") && isScreenshot(String(filename.dropFirst())) {
            let finalFilename = String(filename.dropFirst())
            let finalPath = URL(fileURLWithPath: path)
                .deletingLastPathComponent()
                .appendingPathComponent(finalFilename)
                .path
            guard hasNotProcessed(finalPath) else { continue }
            print("📸 Detected incoming screenshot: \(filename)")
            Task {
                try? await Task.sleep(nanoseconds: delayInSeconds * 1_000_000_000)
                await renameScreenshot(finalPath)
            }
            continue
        }

        // Pattern match file here:
        guard isScreenshot(filename) else { continue }
        guard hasNotProcessed(path) else { continue }

        print("📸 New screenshot: \(filename)")
        Task { await renameScreenshot(path) }
    }
}

var context = FSEventStreamContext()
let stream = FSEventStreamCreate(
    nil, callback, &context,
    [desktopURL.path] as CFArray,
    FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
    0.5,
    FSEventStreamCreateFlags(kFSEventStreamCreateFlagFileEvents | kFSEventStreamCreateFlagUseCFTypes)
)!

FSEventStreamSetDispatchQueue(stream, queue)
FSEventStreamStart(stream)

print("👀 Watching for screenshots on Desktop...")
dispatchMain()

Note you can edit the following settings in your file based on your preferences:

let anthropicModel: String = "claude-haiku-4-5"
let folderName: String = "Desktop"
let maxProcessed: Int = 50
let delayInSeconds: UInt64 = 1
let systemPrompt: String = """
Create descriptive title for screenshot in 2-6 words, lowercase, hyphen-separated. Return only the filename with no extension. Example: safari-github-pull-request
"""

func isScreenshot(_ filename: String) -> Bool {
    filename.hasPrefix("Screenshot") && filename.hasSuffix(".png")
}

NOTE: Make sure to recompile the binary and unload then reload the process if editing.

Then when you are ready to test you can run the following in your terminal:

# run in same folder file is saved:
xcrun swiftc ai-rename-screenshot.swift -o ai-rename-screenshot

# verify new program was generated
ls

# this will output a binary you can run by:
./ai-rename-screenshot

Then take a screenshot while it’s running and your output should look like the following now:

 swift-scripts $ xcrun swiftc ai-rename-screenshot.swift -o ai-rename-screenshot
 swift-scripts $ ls
ai-rename-screenshot       ai-rename-screenshot.swift
 swift-scripts $ ./ai-rename-screenshot
👀 Watching for screenshots on Desktop...
Event: .Screenshot 2026-04-16 at 7.05.51 AM.png flags: 110848
📸 Detected incoming screenshot: .Screenshot 2026-04-16 at 7.05.51 AM.png
Event: .Screenshot 2026-04-16 at 7.05.51 AM.png flags: 112896
Event: Screenshot 2026-04-16 at 7.05.51 AM.png flags: 100352
Event: Screenshot 2026-04-16 at 7.05.51 AM.png flags: 98304
Event: Screenshot 2026-04-16 at 7.05.51 AM.png flags: 4260864
📦 Compressed to 172KB
 Renamed to: swift-script-screenshot-watcher.png
Event: Desktop flags: 132096
Event: Screenshot 2026-04-16 at 7.05.51 AM.png flags: 98304
Event: Screenshot 2026-04-16 at 7.05.51 AM.png flags: 67584
Event: swift-script-screenshot-watcher.png flags: 100352
Event: swift-script-screenshot-watcher.png flags: 98304
Event: swift-script-screenshot-watcher.png flags: 98304

Add to launchd

The next step is to add this binary to launchd so that it runs when the computer is started, to do so let’s create a new .plist file in the same directory as our binary:

# create .plist file in same directory:
cat > ~/Library/LaunchAgents/com.asleepace.ai-rename-screenshot.plist << EOF
<?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.asleepace.ai-rename-screenshot</string>
    <key>ProgramArguments</key>
    <array>
        <string>$HOME/swift-scripts/ai-rename-screenshot</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
    <key>StandardOutPath</key>
    <string>/tmp/ai-rename-screenshot.log</string>
    <key>StandardErrorPath</key>
    <string>/tmp/ai-rename-screenshot.log</string>
</dict>
</plist>
EOF

# add .plist entry to launchd
launchctl load ~/Library/LaunchAgents/com.asleepace.ai-rename-screenshot.plist

NOTE: Make sure this line <string>$HOME/swift-scripts/ai-rename-screenshot</string> expands to the actual home directory (e.g. <string>/Users/YOUR_USERNAME/swift-scripts/ai-rename-screenshot</string>)

Then go ahead and take a screenshot, the first time you may see these alerts:

System Notifications
MacOS permission dialogueMacOS Background Items

Go ahead and click “Allow”, you will be able to edit this later in System SettingsLogin Items & Extensions

System settings preferences

Now go ahead and take some screenshots and watch the clutter turn into organization!

Example Desktop Output

Troubleshooting

Here are some useful resources, snippets and troubleshooting tips for this tutorial.

Checking Screenshots Save Location

Useful commands for checking MacOS Screenshot settings:

# check where screenshots are being saved:
defaults read com.apple.screencapture location

# change screenshot save location to Desktop:
defaults write com.apple.screencapture location ~/Desktop

# apply changes:
killall SystemUIServer

Automator Commands

Useful commands for programmatically interacting with MacOS Automator:

# list all folder actions:
ls ~/Library/Scripts/Folder\ Action\ Scripts/

# disable all folder actions:
defaults write com.apple.FolderActionsDispatcher UserFolderActionsEnabled -bool false

# re-enable:
defaults write com.apple.FolderActionsDispatcher UserFolderActionsEnabled -bool true

# delete workflow:
rm ~/Library/Workflows/Applications/Folder\ Actions/YOUR_WORKFLOW_NAME.workflow

Setting / Getting Keychain Secret

Useful commands for MacOS Keychain:

# get keychain secret
security find-generic-password -a "$USER" -s "CLAUDE_API_KEY" -w

# set keychain secret
security add-generic-password -a "$USER" -s "CLAUDE_API_KEY" -w "sk-ant-..."

# delete keychain secret
security delete-generic-password -a "$USER" -s "CLAUDE_API_KEY"

Useful launchd commands

Here are some useful Bash commands for managing this launchd process:

# stop program:
launchctl unload ~/Library/LaunchAgents/com.asleepace.ai-rename-screenshot.plist

# start process:
launchctl load ~/Library/LaunchAgents/com.asleepace.ai-rename-screenshot.plist

# observe process log:
tail -f /tmp/ai-rename-screenshot.log 

# check process is running:
launchctl list | grep asleepace