TLDR: If you're building a shellout to osascript, passing newlines in interpolated string literals silently kills delivery. Pass data as argv. Never mix data into source.

The Setup

I built a little monitoring framework — a system that polls a condition on a schedule and notifies me when something trips. Think: "remind me when the Apple event date is confirmed," or "alert me if this email thread goes quiet too long."

Two notify channels: Note-to-Self (the default) and Things 3, my task manager (the override, for items I want to track and close).

The framework shipped. Everything felt solid. Note-to-Self was delivering.

The Things channel? Completely silent.

What Was Actually Happening

Here's the thing — it wasn't crashing. No stack traces. No visible errors. The watch would run, the condition would trip, the notify call would fire… and nothing would show up in Things 3.

Turns out _build_message() always appended a \n to the task notes. Makes sense — you want a newline at the end of a message.

But we were passing that string interpolated directly into the AppleScript source — embedded inside a "..." literal.

A raw newline inside an AppleScript string literal is a compile error.

So osascript was exiting nonzero every single time. The task was never created. The watch never retired. And nobody was reading the exit code.

(This one stings, because I have a hard rule on this: HTTP 200 ≠ success — always verify stop_reason and non-empty content. Same principle. I just didn't apply it to my own shellout.)

Why It Hid So Long

The Things channel is the override. Note-to-Self is the default. So every watch I'd actually exercised was going through a path that worked.

The broken path was there from day one — just… never triggered in practice. Latent.

It took Grok 4.5 (xAI's model, which I've been using as an adversarial advisor on my own codebase) to surface it. Not because I did anything clever — Grok just read the code and flagged it. I vetted the finding myself, confirmed it was real, and shipped the fix same day as commit 7945997.

The Fix That Actually Worked

Don't escape the newline. Don't \n-encode it. Don't try to sanitize the string at all.

Pass name and notes as argv, not as source.

The AppleScript handler looks like this:

on run argv
  set taskName to item 1 of argv
  set taskNotes to item 2 of argv
  tell application "Things3"
    make new to do with properties {name:taskName, notes:taskNotes}
  end tell
end run

Call it from Python like:

subprocess.run(["osascript", script_path, name, notes], check=True)

Now the newline is just bytes in a separate argument. The AppleScript compiler never sees it. Things 3 gets the full notes, formatted correctly, every time.

Why This Actually Matters

This is the exact same principle as parameterized SQL: never build a command by concatenating data into source.

Whether it's osascript, sh -c, a templated database query, or a Jinja prompt — data-in-source means your data is parsed as syntax. Quotes bite you. Newlines bite you. Dollar signs bite you.

Pass data as arguments. Let the target read them as values.

That's the whole lesson. Took a silent broken notification channel, four days, and a Grok adversarial pass to knock it loose — but now I won't forget it.

P.S. Grok also caught three other bugs in the same review — one of them was a single malformed watch aborting the entire tick. Adversarial code review from a second model is underrated. Try it on your own systems.