Scripting fm, Apple's Foundation Models CLI | Pete Goldsmith
Back to blog
Jul 2, 2026
Scripting fm, Apple's Foundation Models CLI
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. What came out the other end: Apple built fm and the Python SDK to be scripted, but only for the local model. Private Cloud Compute is guarded in such a way that tries to ensure the PCC model is used from the terminal.
The local model scripts fine. The cloud one is fenced.
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. So the blessed way to script the cloud model is the CLI, not the library. The asker replies with the obvious translation:
ai_response = subprocess.check_output(<br>["fm", "respond", query, "--model", "pcc"], text=True<br>You can try that, but it doesn’t work…
What sets it off
There is a gate in fm called ParentProcessGate. Before it lets a PCC request leave the machine it wants two things: a genuine terminal, and an Apple-signed process that launched it.
A terminal. The gate fails when none of fm’s three standard file descriptors is a tty. Running fm from cron, launchd, a CI runner, or an agent that wires all three descriptors to pipes leaves it with no terminal at all, and PCC refuses. I tested the descriptor rule directly; the rest follow from it, since none of those attaches a tty by default. You can reproduce it at your own prompt by taking all three descriptors off the terminal:
fm respond --model pcc 'hi' /dev/null >/dev/null 2>&1 # exits 1, no output<br>Interestingly the terminal also has to report a non-zero window size. A real one always does, so you never notice, but a pseudo-terminal conjured by a script can have a size of zero, and the gate refuses that with a log of “likely script(1) wrapper”. A meat bag at a terminal clears both checks.
An Apple-signed launcher. This is the one that catches the subprocess call above. The gate walks fm’s process tree and checks that the process that launched it, the one sharing fm’s terminal, is signed by Apple. Your shell is, so typing the command works. A terminal emulator like Ghostty or iTerm never enters into it: the emulator sits on the far side of the pty, not in fm’s session, so its own signature is never checked. A launcher that shares fm’s terminal directly does get checked, and the Python you installed from Homebrew is not an Apple binary:
$ python3 -c 'import subprocess; subprocess.run(["fm","respond","hi","--model","pcc"])'<br>Error: PCC inference is not available in this context.<br>That fails with the terminal fully attached. Swap the Homebrew python3 for the system /usr/bin/python3, which Apple signs, and the same line answers you. The check is on who launched fm, not on what fm is doing.
Under the hood
The check is in the binary, not the framework. The only binary I can find the error string in is /usr/bin/fm; it is not in the FoundationModels or modelmanagerd binaries I searched. It sits with the code that raises it:
ParentProcessGate<br>rule1 FAILED: no tty on any std fd<br>ParentProcessGate also guards the interactive chat UI. It logs every verdict:
$ log show --last 1m --predicate 'processImagePath ENDSWITH "/fm"' | grep ParentProcessGate<br>fm: [com.apple.fm:ParentProcessGate] rejected pid=85479<br>From the disassembly it checks the terminal’s window size with TIOCGWINSZ and validates the ancestry with SecCodeCheckValidity against the code-signing requirement anchor apple, which matches only Apple’s own OS binaries, not Developer ID or notarized third-party apps (those chain through anchor apple generic). When it accepts, the log fills with SecTrustCopyAppleTrustAnchors activity, consistent with that check pulling in Apple’s trust anchors. None of this runs for the on-device system model; only the metered cloud path triggers it.
No flag turns it off. The dumped command tree has no hidden option, and I found no environment variable that changes it. fm has no getenv import, though it does link NSProcessInfo, so I can’t fully rule one out from the symbols alone. No entitlement is involved either: fm carries no PCC entitlement.
Why it is there: your quota
Private Cloud...