Clipboard transform filters

Is it possible to prevent a clipboard transform from executing on non-text? I have an app that can put an image or a url into the clipboard and I’d like to transform only the url and leave images untouched.

Can you be more specific? I'm not sure I fully understand your use-case. (maybe it's just me)

Sure!
I use shottr for screenshots and back in Nov they added a feature to upload images to remote S3 compatible object store and insert the signed url into your clipboard. Those signed urls are pretty gnarly so I was looking to pass them through my url shortener prior to using them.

My attempted solution thus far was to add “System Clipboard Change” trigger to the Shottr app within BTT. Then add a “Transform Clipboard Contents with Java Script” action with hopes to call out to my shortener service.

Now, that all works great when Shottr puts text into the system clipboard but if it puts the image itself into the clipboard, BTT tries to handle it and passes “(null)” to the transform function. I could find no way of telling BTT not to transform images or a way to abort the transform function leaving the clipboard untouched.

Take a look at the below code, it checks what's in the clipboard and if it is a text or a picture. Maybe you can edit it to suit your needs? This code itself is updating a BTT menubar item, but you can execute BTT action within the script itself I believe.

async function get_clipboard() {
  // First try plain text
  let textResult = await get_items_from_clipboard_manager({
    start: 0,
    numberOfItems: 1,
    format: "public.utf8-plain-text",
    asBase64: false
  });

  let parsedText = JSON.parse(textResult);
  let custom_menubar_clipboard_full = (parsedText.items.length > 0) ? parsedText.items[0].content : "";

  // If no text, check if it's an image
  if (!custom_menubar_clipboard_full) {
    let imageResult = await get_items_from_clipboard_manager({
      start: 0,
      numberOfItems: 1,
      format: "public.png",   // could also try public.tiff, public.jpeg
      asBase64: true
    });

    let parsedImage = JSON.parse(imageResult);
    let imageClipboard = (parsedImage.items.length > 0) ? parsedImage.items[0].content : "";

    if (imageClipboard) {
      await set_string_variable({
        variableName: "custom_menubar_clipboard",
        to: "[PICTURE]"
      });
      return "[PICTURE]";
    }

    // nothing useful in clipboard
    return "";
  }

  // If it was text, take only the first line
  let custom_menubar_clipboard = String(custom_menubar_clipboard_full).split(/\r?\n/)[0];

  // Truncate to 18 chars + ellipsis if needed
  if (custom_menubar_clipboard.length > 18) {
    custom_menubar_clipboard = custom_menubar_clipboard.substring(0, 18) + "...";
  }

  await set_string_variable({
    variableName: "custom_menubar_clipboard",
    to: custom_menubar_clipboard
  });

  return custom_menubar_clipboard;
}

Unfortunately the standard clipboard transformer functions don't support this usecase yet, I should enhance them to allow this.

However I think it should be possible to use a custom "run real javascript" action like this:

async function someJavaScriptFunction() {
   // get both of the required clipboard formats
    let image = await get_clipboard_content({format: "public.png", asBase64: true, excludeConcealed: false});
    let url = await get_clipboard_content({format: "NSPasteboardTypeURL", asBase64: false, excludeConcealed: false});

	//transform your url
	let transformedURL = url + "test";

// write back both of the contents
 await set_clipboard_contents({
    contents: [image, transformedURL],
    formats: ["public.png", "NSPasteboardTypeURL"]
  });
  
		return transformedURL;
}

To figure out the "format" parameters you can have a look at BetterTouchTool's clipboard manager here:

Thank you both for your help!

Andreas, you suggestion worked but I believe I’ve found two bugs in BTT.

The first, occasionally, BTT would segfault when reading an image from the clipboard but I was never able to accurately reproduce this so I’ll keep an eye out.

The second, when set_clipboard_contents or set_clipboard_content writes type public.png to the clipboard it also writes the hex to public.utf8-plain-text.

My code:


const imageFormat = "public.png"
const textFormat = "public.utf8-plain-text"

async function clipboardTransformer() {
  try {
    const requests = [
      { format: imageFormat, asBase64: true, excludeConcealed: false },
      { format: textFormat, asBase64: false, excludeConcealed: false }
    ]

    console.log("ClipboardTransformer: get_clipboard_content")
    const cbContents = await Promise.all(
      requests.map(async (request) => ({
        format: request.format,
        content: await get_clipboard_content(request),
      }))
    );

    console.log("ClipboardTransformer: transform")
    const transformed = await Promise.all(
      cbContents
        .filter((cbData) => cbData.content != null && cbData.content !== "")
        .map(maybeTransform)
    );

    console.log("ClipboardTransformer: aggregate")
    const outputs = {
      contents: transformed.map(t => t.content),
      formats: transformed.map(t => t.format),
    };

    console.log("ClipboardTransformer: set_clipboard_contents: " + JSON.stringify(outputs))
    return await set_clipboard_contents(outputs)
  }
  catch (e) {
    console.error(e)
  }
}

async function maybeTransform(cbData) {
  if (cbData.format !== textFormat) {
    return Promise.resolve(cbData)
  }

  return Promise.resolve({
    ...cbData,
    content: cbData.content + "-testing"
  })
}

Logs from hitting “Run Script” twice:

----------------------------
First Image Run
----------------------------
2026/03/11 20:12:38:975|ASL|setting up context|
2026/03/11 20:12:38:978|JS Console [log]|ClipboardTransformer: get_clipboard_content|
2026/03/11 20:12:38:978|JS Console [log]|getting clipboard content|
2026/03/11 20:12:38:978|JS Console [log]|getting clipboard content|
2026/03/11 20:12:38:979|JS Console [log]|ClipboardTransformer: transform|
2026/03/11 20:12:38:979|JS Console [log]|ClipboardTransformer: aggregate|
2026/03/11 20:12:38:979|JS Console [log]|ClipboardTransformer: set_clipboard_contents: {"contents":["iVBORw0KGgoAAAANSUhEUgAAAAoAAAAUCAYAAAC07qxWAAAAAXNSR0IArs4c6QAAAGxlWElmTU0AKgAAAAgABAEaAAUAAAABAAAAPgEbAAUAAAABAAAARgEoAAMAAAABAAIAAIdpAAQAAAABAAAATgAAAAAAAACQAAAAAQAAAJAAAAABAAKgAgAEAAAAAQAAAAqgAwAEAAAAAQAAABQAAAAAjoJ4EAAAAAlwSFlzAAAWJQAAFiUBSVIk8AAAAB9JREFUKBVj5OHh+c9ABGAiQg1YyahCvCE1GjxDPngA40kBS9yjrO8AAAAASUVORK5CYII="],"formats":["public.png"]}|


----------------------------
Second Image Run
----------------------------
2026/03/11 20:12:39:634|ASL|setting up context|
2026/03/11 20:12:39:638|JS Console [log]|ClipboardTransformer: get_clipboard_content|
2026/03/11 20:12:39:638|JS Console [log]|getting clipboard content|
2026/03/11 20:12:39:638|JS Console [log]|getting clipboard content|
2026/03/11 20:12:39:638|JS Console [log]|ClipboardTransformer: transform|
2026/03/11 20:12:39:638|JS Console [log]|ClipboardTransformer: aggregate|
2026/03/11 20:12:39:638|JS Console [log]|ClipboardTransformer: set_clipboard_contents: {"contents":["iVBORw0KGgoAAAANSUhEUgAAAAoAAAAUCAYAAAC07qxWAAAAAXNSR0IArs4c6QAAAGxlWElmTU0AKgAAAAgABAEaAAUAAAABAAAAPgEbAAUAAAABAAAARgEoAAMAAAABAAIAAIdpAAQAAAABAAAATgAAAAAAAACQAAAAAQAAAJAAAAABAAKgAgAEAAAAAQAAAAqgAwAEAAAAAQAAABQAAAAAjoJ4EAAAAAlwSFlzAAAWJQAAFiUBSVIk8AAAAB9JREFUKBVj5OHh+c9ABGAiQg1YyahCvCE1GjxDPngA40kBS9yjrO8AAAAASUVORK5CYII=","iVBORw0KGgoAAAANSUhEUgAAAAoAAAAUCAYAAAC07qxWAAAAAXNSR0IArs4c6QAAAGxlWElmTU0AKgAAAAgABAEaAAUAAAABAAAAPgEbAAUAAAABAAAARgEoAAMAAAABAAIAAIdpAAQAAAABAAAATgAAAAAAAACQAAAAAQAAAJAAAAABAAKgAgAEAAAAAQAAAAqgAwAEAAAAAQAAABQAAAAAjoJ4EAAAAAlwSFlzAAAWJQAAFiUBSVIk8AAAAB9JREFUKBVj5OHh+c9ABGAiQg1YyahCvCE1GjxDPngA40kBS9yjrO8AAAAASUVORK5CYII=-testing"],"formats":["public.png","public.utf8-plain-text"]}|

It's possible that large images cause issues as they are transformed to base64 and fully posted to the JS context. If you can, please provide the crashlog from macOS console app's crash report section.

That was my thought too but even a screenshot across all 3 monitors didn’t bother it.

I’ve attached several of the crashlogs I had, just in case.

BetterTouchTool-2026-03-11-195934.ips (45.4 KB)

BetterTouchTool-2026-03-11-194741.ips (42.3 KB)

BetterTouchTool-2026-03-11-194549.ips (53.4 KB)

BetterTouchTool-2026-03-11-193333.ips (56.6 KB)

BetterTouchTool-2026-03-11-192952.ips (49.7 KB)

BetterTouchTool-2026-03-11-190855.ips (42.1 KB)

BetterTouchTool-2026-03-11-190333.ips (44.7 KB)

BetterTouchTool-2026-03-11-190226.ips (45.6 KB)

BetterTouchTool-2026-03-11-185958.ips (44.2 KB)

BetterTouchTool-2026-03-11-185804.000.ips (48.0 KB)

Thank you! Could you check whether 6.248 fixes the crash?

I wasn’t able to reproduce it.
What about the other issue with set_clipboard_contentssetting the text format even though it wasn’t being passed?

Does that still happen? (I also tried to fix that, but possibly I missed something)

Oh I hadn’t realized! Just tested it and it seems fixed. Thanks!

My last remaining issue, which I just discovered, is BTT never thinks Shottr is the application doing the copying. I’m guessing it’s either a polling issue (there aren’t clipboard hooks iirc) and the window closes too fast or focus changes before setting the clipboard?

Do you have any thoughts on how to resolve that?

I ended up using a custom variable to track the last opened app and it seems to be working. Happy for suggestions is there is a better way.

That sounds good, most likely Shottr never actually becomes the "active app"