macOS 27 ships with fm, a cli to Apple’s Foundation Models. It was introduced at WWDC26 in the session Build AI-powered scripts with the fm CLI and Python SDK, and it does the things: it reads stdin, streams stdout, sets exit codes, emits JSON, and doesn’t need an API key. Two models are on offer: system, the on-device one, free and always available; and pcc, the larger Apple model that runs on Private Cloud Compute.

The fm --help screen on macOS 27.
So I tried to script it. The on-device model was happy to be automated. The cloud model was not. fm respond --model pcc answered fine when I typed it, then failed when the same command was invoked from a script, and the failure moved around depending on how the script was set up. Apple built fm and the Python SDK to be scripted, but only for the local model. Private Cloud Compute is guarded to keep the PCC model tied to a terminal.
Scripting fm is straightforward, unless you use the Private Cloud Compute model
The split shows up in the SDK too. In issue #13 on the Python SDK, someone asks whether Private Cloud Compute is coming to it. A maintainer says no, and points at the CLI instead: you reach PCC through fm, and fm serve exposes it as a Chat Completions endpoint. The asker replies with the obvious translation:
ai_response = subprocess.check_output(
["fm", "respond", query, "--model", "pcc"], text=True
)
You can try that. It doesn’t work.
Poking fm
After seeing Error: PCC inference is not available in this context:
$ python3 -c 'import subprocess; subprocess.run(["fm","respond","hi","--model","pcc"])'
Error: PCC inference is not available in this context.
I went hunting in console for debug logs, and gladly found substantial messaging for how the gate works:
$ log stream --debug --predicate 'subsystem == "com.apple.fm"'
Run the failing call again with the stream open, and fm logs why it refused:
fm: [com.apple.fm:ParentProcessGate] rule1 stdin=true stdout=true stderr=true
fm: [com.apple.fm:ParentProcessGate] rule2 OK fmTTY=268435458
fm: [com.apple.fm:ParentProcessGate] climb[0] pid=10824 path=/opt/homebrew/…/Python tdev=268435458 hasAuditToken=true
fm: [com.apple.fm:ParentProcessGate] boundary[0] pid=10824 tdev=268435458 == fmTTY — in-session, classifying
fm: [com.apple.fm:ParentProcessGate] matches requirement="anchor apple" SecCodeCheckValidity status=-67050
fm: [com.apple.fm:ParentProcessGate] appleSigned pid=10824 via=audit-token result=false
fm: [com.apple.fm:ParentProcessGate] boundary[0] pid=10824 FAILED: in-session ancestor unclassified
fm: [com.apple.fm:ParentProcessGate] rejected pid=10825
ParentProcessGate
There’s a gate, ParentProcessGate. It confirmed a terminal was attached (rule1, rule2), climbed the process tree, found my Homebrew python3 sharing fm’s terminal, checked it against the code-signing requirement anchor apple, and rejected it for not being Apple’s. The rules are numbered, but not all of them print their number — there’s a rule1 and a rule2 here and a rule4 elsewhere, and rule3 never announces itself. To get the full set I pulled the strings out of the binary:
$ strings $(which fm) | grep -i rule
rule1 FAILED: no tty on any std fd
rule2 FAILED: no controlling tty for self
rule4 winsize cols=
rule4 FAILED: zero winsize
rule4 FAILED: TIOCGWINSZ ioctl errno=
Four checks, in the order the gate runs them:
- Rule 1 — one of stdin/stdout/stderr has to be a tty.
- Rule 2 —
fmmust own a controlling terminal, not just borrow a descriptor from one. - Rule 3 — the walk up the process tree (up to 64 ancestors) has to reach a real session. This is the one with no string; it logs as
walkToSessionBoundaryandancestorUnclassified. - Rule 4 — that terminal has to report a non-zero window size, read with
TIOCGWINSZ.
Why gate scripting PCC?
Private Cloud Compute is metered, and you never signed in to meter it against. There is no login and no API key. Apple’s WWDC session frames the daily allowance as tied to your Apple Account and upgradeable through iCloud+, which fits PCC needing Apple Intelligence turned on, and that needs a signed-in account. The local logs only show requests attributed to com.apple.fm, not an account, so I can’t confirm the exact bucket from here. Either way, fm quota-usage reports the cloud model as available right up until it isn’t.
Bad actors could shell out to fm respond --model pcc and draw down your PCC allowance without your awareness. Apple already keeps PCC away from most third-party apps with a request-only entitlement; the terminal check is the cruder equivalent on the CLI side, aimed at the same thing: stopping code you did not write from spending a resource billed to you. It stops the naive path cold, a subprocess call with no terminal or a launcher that is not Apple-signed. It is not a wall, though. As the pty trick below shows, a determined process can fake a terminal, so it raises the cost of casual quota-draining rather than proving a person is there. (A sandboxed App Store app may not be able to launch fm at all, which I did not test, so take the threat as the unsandboxed case.) It checks presence, not permission.
How Apple wants fm held for PCC
fm serve is the sanctioned way for tools to locally interact with PCC. Start it from a terminal and /health reports the cloud model available:
$ curl -s localhost:1976/health
{"status":"fm serve is running","models":[{"name":"system","available":true},{"name":"pcc","available":true}]}
From there a script, a cron job, or an agent can post to localhost:1976/v1/chat/completions with "model": "pcc" and get Private Cloud Compute without touching the gate. The check runs once, at server launch, against whoever started it, so a meat bag starting the server satisfies it for every later request. Start the same server headless, with no terminal, and the endpoint reports pcc unavailable.
Bypassing ParentProcessGate
The gate wants a pseudo-terminal with a real window size and an Apple-signed process above fm on that terminal. You can manufacture the first and inherit the second: give fm its own pty, and your non-Apple interpreter is no longer sharing its terminal (the same reason Ghostty is exempt), while the ancestry walk still resolves to the Apple-signed shell above. It’s a pty.fork, one ioctl to set the window size, and a loop to read the reply:
import pty, os, struct, fcntl, termios
pid, fd = pty.fork()
if pid == 0:
os.execvp("fm", ["fm", "respond", "--model", "pcc", "hi"])
fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", 40, 120, 0, 0))
out = b""
while True:
try:
chunk = os.read(fd, 65536)
except OSError: # the pty closing surfaces as EIO on macOS — that's EOF
break
if not chunk:
break
out += chunk
os.waitpid(pid, 0)
print(out.decode(errors="replace"))
I ran that from a Homebrew Python in a headless shell, the exact combination that gets rejected two ways over, and PCC answered; ParentProcessGate logged accepted. The ioctl gives the pseudo-terminal a window size to report, which clears rule 4. The read loop is there because fm streams the reply in pieces: a single os.read returns only the first, so you read until the pty closes, which on macOS arrives as an OSError.